Skip to content

feat(tutti): add workspace app runtime facade#28

Open
hugozhou-ai wants to merge 7 commits into
mainfrom
codex/tutti-app-runtime-facade
Open

feat(tutti): add workspace app runtime facade#28
hugozhou-ai wants to merge 7 commits into
mainfrom
codex/tutti-app-runtime-facade

Conversation

@hugozhou-ai

@hugozhou-ai hugozhou-ai commented Jul 10, 2026

Copy link
Copy Markdown

Summary

  • add a server-only Tutti workspace app runtime facade for provider catalog discovery and managed/local execution
  • centralize Tutti/runtime provider identifiers and scoped daemon API access
  • align ACP lifecycle, prompt, permission, model, update, and MCP environment handling with the protocol

Validation

  • corepack pnpm typecheck
  • corepack pnpm test (167 tests)
  • corepack pnpm build
  • corepack pnpm pack:check

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-kit to 0.3.0-beta.2.

  • New Features

    • @tutti-os/agent-acp-kit/tutti: createTuttiAgentAppRuntime() for workspace apps (auto/tutti/standalone via TUTTI_* env).
    • Scoped provider catalog: intersects Tutti daemon readiness with local runtime; per-provider composer options and model defaults; exposes runtimeProviderId.
    • prepareRun() for run-scoped setup; returns one-shot execute() and exposes executionMode ("local" | "managed") without exposing credentials.
    • Export createDefaultLocalAgentRuntime; docs add workspace-app quick start and subpath guidance.
  • Bug Fixes

    • ACP lifecycle: send client capabilities; non-blocking lifecycle with accurate completed/canceled semantics.
    • Permissions: reply with selected outcome shape and support allow/approve.
    • Model config: try session/set_config_option with fallback to session/set_model.
    • Prompt: send content blocks; parse nested agent text deltas and sessionUpdate kinds.
    • MCP env: map { key,value } to { name,value }.

Written for commit c8aa332. Summary will update on new commits.

Review in cubic

Signed-off-by: HugoZhou <hugozhou0638@gmail.com>
Signed-off-by: HugoZhou <hugozhou0638@gmail.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +228 to +231
const selectedOptionId =
choosePermissionOutcome(options) ??
options.find((option) => typeof option.optionId === "string")
?.optionId;

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
const selectedOptionId =
choosePermissionOutcome(options) ??
options.find((option) => typeof option.optionId === "string")
?.optionId;
const selectedOptionId = choosePermissionOutcome(options);
Fix with cubic

env: AcpMcpEnvEntry[];
})
| (NormalizedLocalAgentMcpHttpServerConfig & {
| (Omit<NormalizedLocalAgentMcpHttpServerConfig, "env"> & {

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment on lines +59 to +76
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 },
});
}

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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 },
});
Fix with cubic

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
try {
const response = await fetchImplementation(new URL(path, baseUrl), {

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/tutti/provider-id.ts
};

export function normalizeAgentProviderId(providerId: string) {
return providerId.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "");

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/tutti/app-runtime.ts

async *run(input) {
const { headers, providerId, runId, localCwd, ...executionInput } = input;
const prepared = await this.prepareRun({

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/tutti/app-runtime.ts
const available = new Set(
providers.filter((provider) => provider.available).map((provider) => provider.id),
);
if (defaultProviderId && available.has(defaultProviderId)) {

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment on lines +15 to 17
update.sessionUpdate ?? update.type ?? update.kind ?? update.status ?? "",
);

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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 ?? "",
);
Fix with cubic

Comment thread README.md

for await (const event of agents.run({
headers: request.headers,
providerId: catalog.selectedProviderId!,

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant