feat(tutti): add workspace app runtime facade#28
Conversation
Signed-off-by: HugoZhou <hugozhou0638@gmail.com>
Signed-off-by: HugoZhou <hugozhou0638@gmail.com>
Signed-off-by: HugoZhou <hugozhou0638@gmail.com>
Signed-off-by: HugoZhou <hugozhou0638@gmail.com>
There was a problem hiding this comment.
9 issues found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="README.md">
<violation number="1" location="README.md:169">
P3: The quick start can fail when copied into an app with an unavailable catalog or no detected provider: `selectedProviderId` may be `null`, but the example suppresses that with `!` and passes it to `agents.run()`. Consider showing a `catalog.status`/`selectedProviderId` guard before starting the run.</violation>
</file>
<file name="src/transports/acp/acp-types.ts">
<violation number="1" location="src/transports/acp/acp-types.ts:18">
P2: HTTP MCP servers can be rejected by ACP peers because this variant still models an `env` field that is not part of ACP v1 `McpServerHttp`. Consider keeping `env` only on the stdio variant and updating the HTTP serialization path accordingly.</violation>
</file>
<file name="src/testing/fake-acp-peer.ts">
<violation number="1" location="src/testing/fake-acp-peer.ts:59">
P2: A prompt method configured as unsupported can still produce session updates and a successful exit because the new `errorMethods` branch does not stop processing before the `session/prompt` handler. Returning/continuing after the error response would keep the fake peer's failure simulation faithful.</violation>
</file>
<file name="src/tutti/workspace-app-client.ts">
<violation number="1" location="src/tutti/workspace-app-client.ts:110">
P2: Deployments that configure `TUTTI_API_BASE_URL` with a path prefix will send workspace-app catalog requests to the wrong route because the request path starts with `/` and `new URL(path, baseUrl)` resets the base pathname. Consider resolving a relative path against a trailing-slash base so prefixes such as `/api/` are preserved.</violation>
</file>
<file name="src/tutti/provider-id.ts">
<violation number="1" location="src/tutti/provider-id.ts:12">
P2: Malformed provider IDs that contain a known ID plus punctuation are silently accepted as that provider; for example `claude@` becomes `claude-code` and can launch the Claude runtime. Preserving unknown characters (or rejecting invalid IDs before alias lookup) would keep provider selection exact and avoid unintended aliases.</violation>
</file>
<file name="src/tutti/app-runtime.ts">
<violation number="1" location="src/tutti/app-runtime.ts:234">
P2: Destructuring or passing `run` as a callback will fail before execution because it reads `this.prepareRun`. Consider closing over the `prepareRun` implementation instead of depending on dynamic `this` for this public facade method.</violation>
<violation number="2" location="src/tutti/app-runtime.ts:435">
P2: Catalog selection currently ignores a caller's saved/preferred provider whenever a default provider is available. Since `preferredProviderId` is meant to influence `selectedProviderId`, consider checking the preferred id before falling back to `defaultProviderId`.</violation>
</file>
<file name="src/transports/acp/acp-client.ts">
<violation number="1" location="src/transports/acp/acp-client.ts:15">
P2: ACP thought chunks remain invisible to consumers: `sessionUpdate: "agent_thought_chunk"` with `content.text` is parsed but does not match the thinking or text classifiers. Including `thought` in the thinking classification would preserve reasoning updates from ACP providers.</violation>
<violation number="2" location="src/transports/acp/acp-client.ts:228">
P1: Permission requests with no recognized allow option now get auto-selected rather than cancelled because `selectedOptionId` falls back to the first `optionId`. This can choose an unsupported or non-allow permission option; keeping selection limited to `choosePermissionOutcome(options)` preserves the intended allowlist behavior.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
| const selectedOptionId = | ||
| choosePermissionOutcome(options) ?? | ||
| options.find((option) => typeof option.optionId === "string") | ||
| ?.optionId; |
There was a problem hiding this comment.
P1: Permission requests with no recognized allow option now get auto-selected rather than cancelled because selectedOptionId falls back to the first optionId. This can choose an unsupported or non-allow permission option; keeping selection limited to choosePermissionOutcome(options) preserves the intended allowlist behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/transports/acp/acp-client.ts, line 228:
<comment>Permission requests with no recognized allow option now get auto-selected rather than cancelled because `selectedOptionId` falls back to the first `optionId`. This can choose an unsupported or non-allow permission option; keeping selection limited to `choosePermissionOutcome(options)` preserves the intended allowlist behavior.</comment>
<file context>
@@ -214,11 +224,18 @@ export async function* runAcpTransport(
};
if (message.id !== undefined) {
+ const options = params.options ?? [];
+ const selectedOptionId =
+ choosePermissionOutcome(options) ??
+ options.find((option) => typeof option.optionId === "string")
</file context>
| const selectedOptionId = | |
| choosePermissionOutcome(options) ?? | |
| options.find((option) => typeof option.optionId === "string") | |
| ?.optionId; | |
| const selectedOptionId = choosePermissionOutcome(options); |
| env: AcpMcpEnvEntry[]; | ||
| }) | ||
| | (NormalizedLocalAgentMcpHttpServerConfig & { | ||
| | (Omit<NormalizedLocalAgentMcpHttpServerConfig, "env"> & { |
There was a problem hiding this comment.
P2: HTTP MCP servers can be rejected by ACP peers because this variant still models an env field that is not part of ACP v1 McpServerHttp. Consider keeping env only on the stdio variant and updating the HTTP serialization path accordingly.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/transports/acp/acp-types.ts, line 18:
<comment>HTTP MCP servers can be rejected by ACP peers because this variant still models an `env` field that is not part of ACP v1 `McpServerHttp`. Consider keeping `env` only on the stdio variant and updating the HTTP serialization path accordingly.</comment>
<file context>
@@ -1,19 +1,23 @@
+ env: AcpMcpEnvEntry[];
})
- | (NormalizedLocalAgentMcpHttpServerConfig & {
+ | (Omit<NormalizedLocalAgentMcpHttpServerConfig, "env"> & {
type: "http";
- env: LocalAgentMcpEnvEntry[];
</file context>
| if (errorMethods.includes(message.method)) { | ||
| send({ id: message.id, error: { code: -32601, message: "unsupported" } }); | ||
| } else { | ||
| send({ | ||
| id: message.id, | ||
| result: | ||
| message.method === "session/new" | ||
| ? { | ||
| ok: true, | ||
| sessionId, | ||
| models: { | ||
| availableModels: models, | ||
| ...(currentModelId ? { currentModelId } : {}), | ||
| }, | ||
| } | ||
| : { ok: true }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
P2: A prompt method configured as unsupported can still produce session updates and a successful exit because the new errorMethods branch does not stop processing before the session/prompt handler. Returning/continuing after the error response would keep the fake peer's failure simulation faithful.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/testing/fake-acp-peer.ts, line 59:
<comment>A prompt method configured as unsupported can still produce session updates and a successful exit because the new `errorMethods` branch does not stop processing before the `session/prompt` handler. Returning/continuing after the error response would keep the fake peer's failure simulation faithful.</comment>
<file context>
@@ -52,22 +56,29 @@ process.stdin.on("data", (chunk) => {
- }
- : { ok: true },
- });
+ if (errorMethods.includes(message.method)) {
+ send({ id: message.id, error: { code: -32601, message: "unsupported" } });
+ } else {
</file context>
| if (errorMethods.includes(message.method)) { | |
| send({ id: message.id, error: { code: -32601, message: "unsupported" } }); | |
| } else { | |
| send({ | |
| id: message.id, | |
| result: | |
| message.method === "session/new" | |
| ? { | |
| ok: true, | |
| sessionId, | |
| models: { | |
| availableModels: models, | |
| ...(currentModelId ? { currentModelId } : {}), | |
| }, | |
| } | |
| : { ok: true }, | |
| }); | |
| } | |
| if (errorMethods.includes(message.method)) { | |
| send({ id: message.id, error: { code: -32601, message: "unsupported" } }); | |
| continue; | |
| } | |
| send({ | |
| id: message.id, | |
| result: | |
| message.method === "session/new" | |
| ? { | |
| ok: true, | |
| sessionId, | |
| models: { | |
| availableModels: models, | |
| ...(currentModelId ? { currentModelId } : {}), | |
| }, | |
| } | |
| : { ok: true }, | |
| }); |
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(), requestTimeoutMs); | ||
| try { | ||
| const response = await fetchImplementation(new URL(path, baseUrl), { |
There was a problem hiding this comment.
P2: Deployments that configure TUTTI_API_BASE_URL with a path prefix will send workspace-app catalog requests to the wrong route because the request path starts with / and new URL(path, baseUrl) resets the base pathname. Consider resolving a relative path against a trailing-slash base so prefixes such as /api/ are preserved.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tutti/workspace-app-client.ts, line 110:
<comment>Deployments that configure `TUTTI_API_BASE_URL` with a path prefix will send workspace-app catalog requests to the wrong route because the request path starts with `/` and `new URL(path, baseUrl)` resets the base pathname. Consider resolving a relative path against a trailing-slash base so prefixes such as `/api/` are preserved.</comment>
<file context>
@@ -0,0 +1,209 @@
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
+ try {
+ const response = await fetchImplementation(new URL(path, baseUrl), {
+ ...init,
+ headers: {
</file context>
| }; | ||
|
|
||
| export function normalizeAgentProviderId(providerId: string) { | ||
| return providerId.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, ""); |
There was a problem hiding this comment.
P2: Malformed provider IDs that contain a known ID plus punctuation are silently accepted as that provider; for example claude@ becomes claude-code and can launch the Claude runtime. Preserving unknown characters (or rejecting invalid IDs before alias lookup) would keep provider selection exact and avoid unintended aliases.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tutti/provider-id.ts, line 12:
<comment>Malformed provider IDs that contain a known ID plus punctuation are silently accepted as that provider; for example `claude@` becomes `claude-code` and can launch the Claude runtime. Preserving unknown characters (or rejecting invalid IDs before alias lookup) would keep provider selection exact and avoid unintended aliases.</comment>
<file context>
@@ -0,0 +1,49 @@
+};
+
+export function normalizeAgentProviderId(providerId: string) {
+ return providerId.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "");
+}
+
</file context>
|
|
||
| async *run(input) { | ||
| const { headers, providerId, runId, localCwd, ...executionInput } = input; | ||
| const prepared = await this.prepareRun({ |
There was a problem hiding this comment.
P2: Destructuring or passing run as a callback will fail before execution because it reads this.prepareRun. Consider closing over the prepareRun implementation instead of depending on dynamic this for this public facade method.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tutti/app-runtime.ts, line 231:
<comment>Destructuring or passing `run` as a callback will fail before execution because it reads `this.prepareRun`. Consider closing over the `prepareRun` implementation instead of depending on dynamic `this` for this public facade method.</comment>
<file context>
@@ -0,0 +1,480 @@
+
+ async *run(input) {
+ const { headers, providerId, runId, localCwd, ...executionInput } = input;
+ const prepared = await this.prepareRun({
+ headers,
+ providerId,
</file context>
| const available = new Set( | ||
| providers.filter((provider) => provider.available).map((provider) => provider.id), | ||
| ); | ||
| if (defaultProviderId && available.has(defaultProviderId)) { |
There was a problem hiding this comment.
P2: Catalog selection currently ignores a caller's saved/preferred provider whenever a default provider is available. Since preferredProviderId is meant to influence selectedProviderId, consider checking the preferred id before falling back to defaultProviderId.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tutti/app-runtime.ts, line 430:
<comment>Catalog selection currently ignores a caller's saved/preferred provider whenever a default provider is available. Since `preferredProviderId` is meant to influence `selectedProviderId`, consider checking the preferred id before falling back to `defaultProviderId`.</comment>
<file context>
@@ -0,0 +1,480 @@
+ const available = new Set(
+ providers.filter((provider) => provider.available).map((provider) => provider.id),
+ );
+ if (defaultProviderId && available.has(defaultProviderId)) {
+ return defaultProviderId;
+ }
</file context>
| update.sessionUpdate ?? update.type ?? update.kind ?? update.status ?? "", | ||
| ); | ||
|
|
There was a problem hiding this comment.
P2: ACP thought chunks remain invisible to consumers: sessionUpdate: "agent_thought_chunk" with content.text is parsed but does not match the thinking or text classifiers. Including thought in the thinking classification would preserve reasoning updates from ACP providers.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/transports/acp/acp-client.ts, line 15:
<comment>ACP thought chunks remain invisible to consumers: `sessionUpdate: "agent_thought_chunk"` with `content.text` is parsed but does not match the thinking or text classifiers. Including `thought` in the thinking classification would preserve reasoning updates from ACP providers.</comment>
<file context>
@@ -11,16 +11,24 @@ function pushSessionUpdateEvents(queue: AgentEvent[], params: unknown) {
>;
- const kind = String(update.type ?? update.kind ?? update.status ?? "");
+ const kind = String(
+ update.sessionUpdate ?? update.type ?? update.kind ?? update.status ?? "",
+ );
</file context>
| update.sessionUpdate ?? update.type ?? update.kind ?? update.status ?? "", | |
| ); | |
| const kind = String( | |
| update.sessionUpdate === "agent_thought_chunk" | |
| ? "thinking_delta" | |
| : update.sessionUpdate ?? update.type ?? update.kind ?? update.status ?? "", | |
| ); |
|
|
||
| for await (const event of agents.run({ | ||
| headers: request.headers, | ||
| providerId: catalog.selectedProviderId!, |
There was a problem hiding this comment.
P3: The quick start can fail when copied into an app with an unavailable catalog or no detected provider: selectedProviderId may be null, but the example suppresses that with ! and passes it to agents.run(). Consider showing a catalog.status/selectedProviderId guard before starting the run.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 166:
<comment>The quick start can fail when copied into an app with an unavailable catalog or no detected provider: `selectedProviderId` may be `null`, but the example suppresses that with `!` and passes it to `agents.run()`. Consider showing a `catalog.status`/`selectedProviderId` guard before starting the run.</comment>
<file context>
@@ -141,6 +141,49 @@ for await (const event of runtime.run({
+
+for await (const event of agents.run({
+ headers: request.headers,
+ providerId: catalog.selectedProviderId!,
+ runId: crypto.randomUUID(),
+ localCwd: workspaceCwd,
</file context>
Summary
Validation
Summary by cubic
Adds a server-only Tutti workspace-app runtime facade to discover provider catalogs and run agents with managed or local execution. Aligns ACP transport with the latest protocol and bumps
@tutti-os/agent-acp-kitto0.3.0-beta.2.New Features
@tutti-os/agent-acp-kit/tutti:createTuttiAgentAppRuntime()for workspace apps (auto/tutti/standalone viaTUTTI_*env).runtimeProviderId.prepareRun()for run-scoped setup; returns one-shotexecute()and exposesexecutionMode("local" | "managed") without exposing credentials.createDefaultLocalAgentRuntime; docs add workspace-app quick start and subpath guidance.Bug Fixes
allow/approve.session/set_config_optionwith fallback tosession/set_model.sessionUpdatekinds.{ key,value }to{ name,value }.Written for commit c8aa332. Summary will update on new commits.