From 71e1f0d111a4a30a241420f67d138dd4c52a3301 Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Wed, 2 Sep 2026 11:03:16 +0200 Subject: [PATCH 1/4] feat(agent-sdk): migrate bridge to 0.3.258 - bump both SDK pins, lockfiles, and runtime guard to 0.3.258 - use summary context usage for polling and guarded-resume fencing - snapshot appended system prompts across resumes until compaction - add focused version, prompt-policy, context-detail, and resume-fence test - document the remaining migration phases and live smoke gates --- agent-sdk/package-lock.json | 72 ++++++++++---------- agent-sdk/package.json | 2 +- agent-sdk/src/bridge.test.ts | 70 ++++++++++++++++++- agent-sdk/src/bridge.ts | 2 +- agent-sdk/src/bridge/command_session_data.ts | 2 +- agent-sdk/src/bridge/session_lifecycle.ts | 5 +- package-lock.json | 72 ++++++++++---------- package.json | 2 +- 8 files changed, 148 insertions(+), 79 deletions(-) diff --git a/agent-sdk/package-lock.json b/agent-sdk/package-lock.json index 5fd444e8..414bd32c 100644 --- a/agent-sdk/package-lock.json +++ b/agent-sdk/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.239" + "@anthropic-ai/claude-agent-sdk": "0.3.258" }, "devDependencies": { "@biomejs/biome": "2.5.11", @@ -22,22 +22,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.239.tgz", - "integrity": "sha512-cIuZhK4u76S5Otq78U890GSA6BFT4SLqOuMqzU/bP/tWRWKhHhNp/3/pvgLwoVGlkdhD7luXWduqXKyLC+VNBQ==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.258.tgz", + "integrity": "sha512-RxJ5fSPCGCxX5qO/b4IPXhldvtLHeYBAzTUJ4eOzO+gTrepZQSDmwSlQD6nnoEquKGJzOMHCjhdEtBfDjbDWUg==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.239", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.239" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.258", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.258" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -46,9 +46,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.239.tgz", - "integrity": "sha512-GGVGuCwFEUm6cMlnBX0LTC9JX5NdGzxddbuqWtRxEgo9EetS70SO3FW+reitALlotHghPTfnICQILbBDIRyX+Q==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.258.tgz", + "integrity": "sha512-Hrhzc9WVGSid+DghdTcpVr/8fyXnTD6KeSlDpKx6Wru47J/Nq7RTYiZJt+cex+O2ehaHMEcuYEgoqJ3K/X9NlA==", "cpu": [ "arm64" ], @@ -59,9 +59,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.239.tgz", - "integrity": "sha512-QNbBXz3Pb3pQ7a+Kcbets6t9IrQhStKsfl5D518nYiGFoRMioO7efkZ6zHUcrGDqDC0LIzrs7tY2KNzH4RfwZA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.258.tgz", + "integrity": "sha512-AVqxGX4988J5cS+TMqIzH85+sbsLhJu5Ou9TIALcO/v2Z9ze8GK4vX2ydAYvU/SRnjTvEaiITX+Xcm5afP1IbQ==", "cpu": [ "x64" ], @@ -72,9 +72,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.239.tgz", - "integrity": "sha512-RE6tDtzU0xj58tsuxnlXMJO8ckJ4tx/1nUgR+D/fPEQVt84oOyXeVspKt1ffvycogh3Sr3MkCGwZrPmxu/V1nA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.258.tgz", + "integrity": "sha512-Jj3K1Ip7WpyMouZCjd7kgV3KswUBF62WAnyG0iaYvKZJvXgYKbIAkjcQ2F2Rx5ZuRUNWAVncE9LeHmdIdx78VQ==", "cpu": [ "arm64" ], @@ -88,9 +88,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.239.tgz", - "integrity": "sha512-Ajc3cuszVdOwfMZVsGdxrCTmgWOeJpQWAIqu8jNEvERIeNnBxvWfGrjmLPxYT3/LJ9Uj/tFTpp52J4IcmrJE5w==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.258.tgz", + "integrity": "sha512-I/BLt2vdvqK2B2px526U1lw7Rv+SI+Ld22+wLwu8gLRQk5SYhSW9dmMYEO+GCeF7vQzfzJvMQ4IzbbY+aSGACg==", "cpu": [ "arm64" ], @@ -104,9 +104,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.239.tgz", - "integrity": "sha512-q4YaDoPgqh0XM23RM1/Zje7OSKccuCTQE89KoppDFOsyGdRsUj5xr01LTtr5hnYQuZD7dfwAbz9zl1g0MF/7TA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.258.tgz", + "integrity": "sha512-2MJeFVJM/3xwZASP3yn2OuQ9RHIoS30DC/B7oG1XPYcbToPLH4QIfCPLWbSQfqCdp+NEBupLMM9BWpDz4s8Q0g==", "cpu": [ "x64" ], @@ -120,9 +120,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.239.tgz", - "integrity": "sha512-zIUHiG4Romm/t6m/S9n8x4BKluyRCPk0147hPVo4xxkHkp4Di/7TnDMRKO3Wau4x361wRqlE2i5EpYT+CyJjHg==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.258.tgz", + "integrity": "sha512-sM7GzRyrOpFhwMn2Ng8nLiWK6cc04uCEu3Zh9mrJS2r3iQu1TryHKoPTjc2Ip0N75sHmwhNodgoRs8NtG1Gkkw==", "cpu": [ "x64" ], @@ -136,9 +136,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.239.tgz", - "integrity": "sha512-RxA29NdX9g3ZbpcXvSeWxpbg/Eoo3wXfO2eA1Vc7qa5JyAYjrl9xu6dIGAWMiyk/gnFxNh1WoFER77J9P6LTiQ==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.258.tgz", + "integrity": "sha512-n/Vf6oXAo9EZVSSM5+9d+8dFrUrX9cbgSHK/1njkvykWAN5xsfBbikJYqwhbW84GCYkpXYM+gGNZe23h0fHldw==", "cpu": [ "arm64" ], @@ -149,9 +149,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.239.tgz", - "integrity": "sha512-ylKIX0DfaK1EgWYbVEvBMYATVFdKjFcWvvypTIv2sJhM3KxJT/0lTzvqsai8jYeZN1FBHWlRNEUQJvJMjO/diA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.258.tgz", + "integrity": "sha512-UDbXE6n37ZMUogVVYEWX901NNmbyXUv1VvGYN/vfOiIWKUJXCAypam71dBlYFhlAhVX28qCd64w+pTZDYQfYQA==", "cpu": [ "x64" ], diff --git a/agent-sdk/package.json b/agent-sdk/package.json index f1d3c210..3ae509b9 100644 --- a/agent-sdk/package.json +++ b/agent-sdk/package.json @@ -20,7 +20,7 @@ "node": ">=24" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.239" + "@anthropic-ai/claude-agent-sdk": "0.3.258" }, "devDependencies": { "@biomejs/biome": "2.5.11", diff --git a/agent-sdk/src/bridge.test.ts b/agent-sdk/src/bridge.test.ts index 89d2a588..5f8ba10d 100644 --- a/agent-sdk/src/bridge.test.ts +++ b/agent-sdk/src/bridge.test.ts @@ -64,6 +64,7 @@ import { } from "./bridge/mcp.js"; import { emitCurrentModelUpdate, + awaitSessionInitialization, beginSessionClose, closeAllSessions, handleUserDialogResponse, @@ -101,7 +102,10 @@ import { handleResultMessage, } from "./bridge/message_handlers.js"; import { dispatchCancelTurnCommand } from "./bridge/command_dispatch.js"; -import { normalizeStructuredUsage } from "./bridge/command_session_data.js"; +import { + handleSessionDataCommand, + normalizeStructuredUsage, +} from "./bridge/command_session_data.js"; import { handleLifecycleCommand } from "./bridge/command_lifecycle.js"; const BRIDGE_RUNTIME_PROCESS_NAME = @@ -1327,6 +1331,38 @@ test("parseCommandEnvelope validates get_context_usage command", () => { }); }); +test("get_context_usage requests the SDK summary detail", async () => { + sessions.clear(); + const session = makeSessionState(); + const calls: unknown[] = []; + session.query = { + getContextUsage: async (options: unknown) => { + calls.push(options); + return { percentage: 42.4 }; + }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").Query; + sessions.set(session.sessionId, session); + + try { + await captureBridgeEventsAsync(async () => { + await handleSessionDataCommand( + { command: "get_context_usage", session_id: session.sessionId }, + "request-context-summary", + { + generatePersistedSessionTitle: async () => "unused", + buildSessionMutationOptions: () => undefined, + rewindTargetsFromSessionMessages: () => [], + handleRewind: async () => undefined, + }, + ); + }); + + assert.deepEqual(calls, [{ detail: "summary" }]); + } finally { + sessions.clear(); + } +}); + test("parseCommandEnvelope validates get_usage command", () => { const parsed = parseCommandEnvelope( JSON.stringify({ command: "get_usage", session_id: "session-usage" }), @@ -1927,6 +1963,7 @@ test("buildQueryOptions maps launch settings into sdk query options", () => { type: "preset", preset: "claude_code", append: `${BRIDGE_RUNTIME_GUARD_PROMPT} ${GERMAN_LANGUAGE_PROMPT}`, + snapshot: true, }); const _systemPrompt: NonNullable = options.systemPrompt; @@ -1972,6 +2009,12 @@ test("buildQueryOptions includes resumeSessionAt when provided", () => { assert.equal(options.resumeDropsTurn, "user-2"); assert.equal(options.forkSession, true); assert.equal(options.sessionId, "session-1"); + assert.deepEqual(options.systemPrompt, { + type: "preset", + preset: "claude_code", + append: BRIDGE_RUNTIME_GUARD_PROMPT, + snapshot: true, + }); }); test("buildQueryOptions forwards settings and maps startup model and permission mode", () => { @@ -2131,6 +2174,7 @@ test("buildQueryOptions omits optional startup overrides but keeps bridge guard type: "preset", preset: "claude_code", append: BRIDGE_RUNTIME_GUARD_PROMPT, + snapshot: true, }); assert.equal("agentProgressSummaries" in options, false); assert.deepEqual(options.settings, { feedbackDrafts: "off" }); @@ -5096,6 +5140,7 @@ test("buildQueryOptions trims language before appending system prompt", () => { type: "preset", preset: "claude_code", append: `${BRIDGE_RUNTIME_GUARD_PROMPT} ${GERMAN_LANGUAGE_PROMPT}`, + snapshot: true, }); }); @@ -7310,7 +7355,7 @@ test("looksLikeAuthRequired detects login hints", () => { }); test("agent sdk version compatibility check matches pinned version", () => { - assert.equal(resolveInstalledAgentSdkVersion(), "0.3.239"); + assert.equal(resolveInstalledAgentSdkVersion(), "0.3.258"); assert.equal(agentSdkVersionCompatibilityError(), undefined); }); @@ -8160,6 +8205,27 @@ test("handleResultMessage captures resumeDropsTurn refusal before candidate comm assert.equal(session.initializationError, refusal); }); +test("awaitSessionInitialization uses summary context usage as the guarded-resume fence", async () => { + const session = makeSessionState(); + const calls: unknown[] = []; + session.connected = false; + session.deferConnect = true; + session.resumeDropsTurn = "user-2"; + session.initializationReady = true; + session.initializationTask = Promise.resolve(); + session.query = { + getContextUsage: async (options: unknown) => { + calls.push(options); + return { percentage: 1 }; + }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").Query; + + await awaitSessionInitialization(session); + + assert.deepEqual(calls, [{ detail: "summary" }]); + assert.equal(session.resumeGuardFenceComplete, true); +}); + test("commitDeferredSession requires the guarded-resume validation fence", () => { sessions.clear(); const session = makeSessionState(); diff --git a/agent-sdk/src/bridge.ts b/agent-sdk/src/bridge.ts index 2d5147ca..a46e2de5 100644 --- a/agent-sdk/src/bridge.ts +++ b/agent-sdk/src/bridge.ts @@ -357,7 +357,7 @@ export function emitAgentConfigOptionUpdate( }); } -const EXPECTED_AGENT_SDK_VERSION = "0.3.239"; +const EXPECTED_AGENT_SDK_VERSION = "0.3.258"; const require = createRequire(import.meta.url); export function resolveInstalledAgentSdkVersion(): string | undefined { diff --git a/agent-sdk/src/bridge/command_session_data.ts b/agent-sdk/src/bridge/command_session_data.ts index f5ae6dfe..3b0f68cf 100644 --- a/agent-sdk/src/bridge/command_session_data.ts +++ b/agent-sdk/src/bridge/command_session_data.ts @@ -208,7 +208,7 @@ async function getContextUsage( return; } try { - const usage = await session.query.getContextUsage(); + const usage = await session.query.getContextUsage({ detail: "summary" }); if (typeof usage.model === "string" && usage.model.trim().length > 0) { session.resolvedRuntimeModelId = usage.model.trim(); refreshCurrentModel(session, true); diff --git a/agent-sdk/src/bridge/session_lifecycle.ts b/agent-sdk/src/bridge/session_lifecycle.ts index f0204838..37bd40a9 100644 --- a/agent-sdk/src/bridge/session_lifecycle.ts +++ b/agent-sdk/src/bridge/session_lifecycle.ts @@ -923,7 +923,7 @@ export async function awaitSessionInitialization( // the protocol barrier proving that startup messages have reached the SDK; yield // once more so the bridge's query consumer can process the already-enqueued result. try { - await session.query.getContextUsage(); + await session.query.getContextUsage({ detail: "summary" }); } catch (error) { const message = error instanceof Error ? error.message : String(error); bridgeLogger.warn({ @@ -1146,6 +1146,9 @@ function systemPromptFromLaunchSettings( type: "preset", preset: "claude_code", append: appendLines.join(" "), + // Keep the prompt prefix stable across resume. Updated host language or guard text + // intentionally takes effect after SDK compaction or in a new session. + snapshot: true, }; } diff --git a/package-lock.json b/package-lock.json index 6d4d09db..73cb5d6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.14.5", "license": "Apache-2.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.239" + "@anthropic-ai/claude-agent-sdk": "0.3.258" }, "bin": { "claude-rs": "bin/claude-rs.js" @@ -22,22 +22,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.239.tgz", - "integrity": "sha512-cIuZhK4u76S5Otq78U890GSA6BFT4SLqOuMqzU/bP/tWRWKhHhNp/3/pvgLwoVGlkdhD7luXWduqXKyLC+VNBQ==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.258.tgz", + "integrity": "sha512-RxJ5fSPCGCxX5qO/b4IPXhldvtLHeYBAzTUJ4eOzO+gTrepZQSDmwSlQD6nnoEquKGJzOMHCjhdEtBfDjbDWUg==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.239", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.239" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.258", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.258" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -46,9 +46,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.239.tgz", - "integrity": "sha512-GGVGuCwFEUm6cMlnBX0LTC9JX5NdGzxddbuqWtRxEgo9EetS70SO3FW+reitALlotHghPTfnICQILbBDIRyX+Q==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.258.tgz", + "integrity": "sha512-Hrhzc9WVGSid+DghdTcpVr/8fyXnTD6KeSlDpKx6Wru47J/Nq7RTYiZJt+cex+O2ehaHMEcuYEgoqJ3K/X9NlA==", "cpu": [ "arm64" ], @@ -59,9 +59,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.239.tgz", - "integrity": "sha512-QNbBXz3Pb3pQ7a+Kcbets6t9IrQhStKsfl5D518nYiGFoRMioO7efkZ6zHUcrGDqDC0LIzrs7tY2KNzH4RfwZA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.258.tgz", + "integrity": "sha512-AVqxGX4988J5cS+TMqIzH85+sbsLhJu5Ou9TIALcO/v2Z9ze8GK4vX2ydAYvU/SRnjTvEaiITX+Xcm5afP1IbQ==", "cpu": [ "x64" ], @@ -72,9 +72,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.239.tgz", - "integrity": "sha512-RE6tDtzU0xj58tsuxnlXMJO8ckJ4tx/1nUgR+D/fPEQVt84oOyXeVspKt1ffvycogh3Sr3MkCGwZrPmxu/V1nA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.258.tgz", + "integrity": "sha512-Jj3K1Ip7WpyMouZCjd7kgV3KswUBF62WAnyG0iaYvKZJvXgYKbIAkjcQ2F2Rx5ZuRUNWAVncE9LeHmdIdx78VQ==", "cpu": [ "arm64" ], @@ -88,9 +88,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.239.tgz", - "integrity": "sha512-Ajc3cuszVdOwfMZVsGdxrCTmgWOeJpQWAIqu8jNEvERIeNnBxvWfGrjmLPxYT3/LJ9Uj/tFTpp52J4IcmrJE5w==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.258.tgz", + "integrity": "sha512-I/BLt2vdvqK2B2px526U1lw7Rv+SI+Ld22+wLwu8gLRQk5SYhSW9dmMYEO+GCeF7vQzfzJvMQ4IzbbY+aSGACg==", "cpu": [ "arm64" ], @@ -104,9 +104,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.239.tgz", - "integrity": "sha512-q4YaDoPgqh0XM23RM1/Zje7OSKccuCTQE89KoppDFOsyGdRsUj5xr01LTtr5hnYQuZD7dfwAbz9zl1g0MF/7TA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.258.tgz", + "integrity": "sha512-2MJeFVJM/3xwZASP3yn2OuQ9RHIoS30DC/B7oG1XPYcbToPLH4QIfCPLWbSQfqCdp+NEBupLMM9BWpDz4s8Q0g==", "cpu": [ "x64" ], @@ -120,9 +120,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.239.tgz", - "integrity": "sha512-zIUHiG4Romm/t6m/S9n8x4BKluyRCPk0147hPVo4xxkHkp4Di/7TnDMRKO3Wau4x361wRqlE2i5EpYT+CyJjHg==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.258.tgz", + "integrity": "sha512-sM7GzRyrOpFhwMn2Ng8nLiWK6cc04uCEu3Zh9mrJS2r3iQu1TryHKoPTjc2Ip0N75sHmwhNodgoRs8NtG1Gkkw==", "cpu": [ "x64" ], @@ -136,9 +136,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.239.tgz", - "integrity": "sha512-RxA29NdX9g3ZbpcXvSeWxpbg/Eoo3wXfO2eA1Vc7qa5JyAYjrl9xu6dIGAWMiyk/gnFxNh1WoFER77J9P6LTiQ==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.258.tgz", + "integrity": "sha512-n/Vf6oXAo9EZVSSM5+9d+8dFrUrX9cbgSHK/1njkvykWAN5xsfBbikJYqwhbW84GCYkpXYM+gGNZe23h0fHldw==", "cpu": [ "arm64" ], @@ -149,9 +149,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.239.tgz", - "integrity": "sha512-ylKIX0DfaK1EgWYbVEvBMYATVFdKjFcWvvypTIv2sJhM3KxJT/0lTzvqsai8jYeZN1FBHWlRNEUQJvJMjO/diA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.258.tgz", + "integrity": "sha512-UDbXE6n37ZMUogVVYEWX901NNmbyXUv1VvGYN/vfOiIWKUJXCAypam71dBlYFhlAhVX28qCd64w+pTZDYQfYQA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index b2c8427a..469c3613 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "README.md" ], "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.239" + "@anthropic-ai/claude-agent-sdk": "0.3.258" }, "scripts": { "prepack": "npm --prefix agent-sdk run build", From 051d4b81c5a6d921d4e35d5f86e35dc94b5b4a93 Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Wed, 2 Sep 2026 19:53:33 +0200 Subject: [PATCH 2/4] feat(chat): support active-turn user message queue - correlate queued prompts with SDK command lifecycle events - insert accepted queued prompts as individual user messages - preserve queued text and images across interruption and failure - add queue status UX with a ten-message capacity limit - cover bridge, state, and transcript lifecycle behavior --- agent-sdk/src/bridge.test.ts | 393 ++++++++++++++++-- agent-sdk/src/bridge.ts | 1 + agent-sdk/src/bridge/command_dispatch.ts | 9 + .../src/bridge/command_session_control.ts | 44 +- agent-sdk/src/bridge/commands.ts | 1 + agent-sdk/src/bridge/logger.ts | 8 + agent-sdk/src/bridge/message_handlers.ts | 138 ++++++ agent-sdk/src/bridge/session_lifecycle.ts | 3 + agent-sdk/src/bridge/shared.ts | 7 +- agent-sdk/src/types.ts | 25 ++ src/agent/client.rs | 5 +- src/agent/events.rs | 19 + src/agent/types.rs | 9 + src/agent/wire.rs | 134 ++++++ src/app/clipboard_image.rs | 2 +- src/app/connect/event_dispatch.rs | 39 +- src/app/connect/mod.rs | 1 + src/app/events/client.rs | 4 + src/app/events/client/turn.rs | 35 +- src/app/events/mod.rs | 2 +- src/app/events/session.rs | 25 +- src/app/events/session_reset.rs | 30 +- src/app/events/tests.rs | 18 +- src/app/events/tests/client_events.rs | 6 +- src/app/events/turn.rs | 328 ++++++++++++++- src/app/input_submit.rs | 244 +++++++++-- src/app/mod.rs | 1 + src/app/slash/mod.rs | 4 + src/app/state/app.rs | 3 + src/app/state/mod.rs | 5 + src/app/state/pending_messages.rs | 185 +++++++++ src/ui/input.rs | 27 +- src/ui/input_rows.rs | 79 +++- tests/integration/helpers.rs | 6 +- tests/integration/state_transitions.rs | 2 + 35 files changed, 1764 insertions(+), 78 deletions(-) create mode 100644 src/app/state/pending_messages.rs diff --git a/agent-sdk/src/bridge.test.ts b/agent-sdk/src/bridge.test.ts index 5f8ba10d..a010ec40 100644 --- a/agent-sdk/src/bridge.test.ts +++ b/agent-sdk/src/bridge.test.ts @@ -100,8 +100,10 @@ import { requestAskUserQuestionAnswers } from "./bridge/user_interaction.js"; import { flushPendingWorkerShutdown, handleResultMessage, + sdkMessageDiagnosticFields, } from "./bridge/message_handlers.js"; import { dispatchCancelTurnCommand } from "./bridge/command_dispatch.js"; +import { handleSessionControlCommand } from "./bridge/command_session_control.js"; import { handleSessionDataCommand, normalizeStructuredUsage, @@ -156,6 +158,20 @@ function makeSessionState(): SessionState { }; } +function promptControlDeps(): Parameters< + typeof handleSessionControlCommand +>[2] { + return { + buildPromptUserMessage, + applySessionEffort, + applySessionAgent, + applySessionFastMode, + emitEffortConfigOptionUpdate, + emitAgentConfigOptionUpdate, + handleReloadPluginsCommand, + }; +} + test("session resume failures carry their operation identity", () => { const events = captureBridgeEvents(() => { emitSessionResumeFailed("source-session", "resume-at-1", "resume rejected"); @@ -537,6 +553,108 @@ test("parseCommandEnvelope validates initialize command", () => { assert.equal(parsed.command.cwd, "C:/work"); }); +test("parseCommandEnvelope requires and preserves prompt message UUID", () => { + const parsed = parseCommandEnvelope( + JSON.stringify({ + command: "prompt", + session_id: "session-123", + message_uuid: "018f4fd3-2c8a-7a0e-a9a0-8f459f44d971", + chunks: [{ kind: "text", value: "next" }], + }), + ); + + assert.deepEqual(parsed.command, { + command: "prompt", + session_id: "session-123", + message_uuid: "018f4fd3-2c8a-7a0e-a9a0-8f459f44d971", + chunks: [{ kind: "text", value: "next" }], + }); + assert.throws( + () => + parseCommandEnvelope( + JSON.stringify({ + command: "prompt", + session_id: "session-123", + chunks: [{ kind: "text", value: "next" }], + }), + ), + /message_uuid/, + ); +}); + +test("AsyncQueue reports closed-input rejection without silently dropping an item", async () => { + const input = new AsyncQueue(); + assert.equal(input.enqueue("accepted"), true); + input.close(); + assert.equal(input.enqueue("rejected"), false); + const iterator = input[Symbol.asyncIterator](); + assert.deepEqual(await iterator.next(), { value: "accepted", done: false }); + assert.deepEqual(await iterator.next(), { value: undefined, done: true }); +}); + +test("prompt control emits a local queue receipt and preserves UUID on SDK input", async () => { + sessions.clear(); + const session = makeSessionState(); + sessions.set(session.sessionId, session); + const messageUuid = "018f4fd3-2c8a-7a0e-a9a0-8f459f44d971"; + + const events = await captureBridgeEventsAsync(async () => { + await handleSessionControlCommand( + { + command: "prompt", + session_id: session.sessionId, + message_uuid: messageUuid, + chunks: [{ kind: "text", value: "queued prompt" }], + }, + "request-prompt", + promptControlDeps(), + ); + }); + + assert.deepEqual(events, [ + { + request_id: "request-prompt", + event: "user_message_queued", + session_id: "session-1", + message_uuid: messageUuid, + }, + ]); + const sdkMessage = await session.input[Symbol.asyncIterator]().next(); + assert.equal(sdkMessage.done, false); + assert.equal(sdkMessage.value?.uuid, messageUuid); + sessions.clear(); +}); + +test("prompt control emits an explicit rejection when SDK input is closed", async () => { + sessions.clear(); + const session = makeSessionState(); + session.input.close(); + sessions.set(session.sessionId, session); + + const events = await captureBridgeEventsAsync(async () => { + await handleSessionControlCommand( + { + command: "prompt", + session_id: session.sessionId, + message_uuid: "closed-message", + chunks: [{ kind: "text", value: "restore me" }], + }, + undefined, + promptControlDeps(), + ); + }); + + assert.deepEqual(events, [ + { + event: "user_message_rejected", + session_id: "session-1", + message_uuid: "closed-message", + reason: "session input is closed", + }, + ]); + sessions.clear(); +}); + test("parseCommandEnvelope validates resume_session command without cwd", () => { const parsed = parseCommandEnvelope( JSON.stringify({ @@ -2218,31 +2336,41 @@ test("dispatchCancelTurnCommand interrupts the matching session query", async () requestId?: string; }> = []; - await dispatchCancelTurnCommand( - { command: "cancel_turn", session_id: "session-1" }, - { - requestId: "request-1", - sessionById: (sessionId) => - sessionId === "session-1" - ? { - query: { - interrupt: async () => { - interruptCount += 1; - receiptReturned = true; - return { still_queued: ["user-message-1"] }; + const events = await captureBridgeEventsAsync(async () => { + await dispatchCancelTurnCommand( + { command: "cancel_turn", session_id: "session-1" }, + { + requestId: "request-1", + sessionById: (sessionId) => + sessionId === "session-1" + ? { + query: { + interrupt: async () => { + interruptCount += 1; + receiptReturned = true; + return { still_queued: ["user-message-1"] }; + }, }, - }, - } - : undefined, - slashError: (sessionId, message, requestId) => { - slashErrors.push({ sessionId, message, requestId }); + } + : undefined, + slashError: (sessionId, message, requestId) => { + slashErrors.push({ sessionId, message, requestId }); + }, }, - }, - ); + ); + }); assert.equal(interruptCount, 1); assert.equal(receiptReturned, true); assert.deepEqual(slashErrors, []); + assert.deepEqual(events, [ + { + request_id: "request-1", + event: "turn_interrupt_receipt", + session_id: "session-1", + still_queued: ["user-message-1"], + }, + ]); }); test("dispatchCancelTurnCommand emits slash error for unknown session", async () => { @@ -2701,7 +2829,9 @@ test("handleTaskSystemMessage preserves valid task-start background and spawn de }); }); const update = events.at(-1)?.update as { - tool_call_update?: { fields?: { task_metadata?: Record } }; + tool_call_update?: { + fields?: { task_metadata?: Record }; + }; }; assert.deepEqual(update.tool_call_update?.fields?.task_metadata, { is_backgrounded: false, @@ -3330,6 +3460,203 @@ test("handleSdkMessage propagates source UUIDs for stream text and tool results" ); }); +test("handleSdkMessage emits main-thread user-message start before streamed content", () => { + const session = makeSessionState(); + + const events = captureBridgeEvents(() => { + handleSdkMessage(session, { + type: "stream_event", + uuid: "assistant-stream", + session_id: "session-1", + parent_tool_use_id: null, + user_message_uuid: "user-message-1", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "partial" }, + }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + }); + + assert.equal(events[0]?.event, "user_message_started"); + assert.deepEqual(events[0], { + event: "user_message_started", + session_id: "session-1", + message_uuid: "user-message-1", + source: "stream_event", + }); + assert.equal(events[1]?.event, "session_update"); +}); + +test("handleSdkMessage maps command lifecycle start to the submitted message UUID", () => { + const session = makeSessionState(); + const events = captureBridgeEvents(() => { + for (const [state, uuid] of [ + ["queued", "lifecycle-queued"], + ["started", "lifecycle-started"], + ["completed", "lifecycle-completed"], + ] as const) { + handleSdkMessage(session, { + type: "command_lifecycle", + state, + command_uuid: "user-message-active-turn", + uuid, + session_id: "session-1", + } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + } + }); + + assert.deepEqual(events, [ + { + event: "user_message_started", + session_id: "session-1", + message_uuid: "user-message-active-turn", + source: "command_lifecycle", + }, + ]); +}); + +test("handleSdkMessage ignores command lifecycle starts without a command UUID", () => { + const session = makeSessionState(); + const events = captureBridgeEvents(() => { + handleSdkMessage(session, { + type: "command_lifecycle", + state: "started", + uuid: "lifecycle-started", + session_id: "session-1", + } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + }); + + assert.deepEqual(events, []); +}); + +test("handleSdkMessage uses assistant and result as correlation fallbacks and forwards queue count", () => { + const assistantSession = makeSessionState(); + const assistantEvents = captureBridgeEvents(() => { + handleSdkMessage(assistantSession, { + type: "assistant", + uuid: "assistant-complete", + session_id: "session-1", + parent_tool_use_id: null, + user_message_uuid: "user-message-2", + message: { role: "assistant", content: [] }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + }); + assert.deepEqual(assistantEvents[0], { + event: "user_message_started", + session_id: "session-1", + message_uuid: "user-message-2", + source: "assistant", + }); + + const resultSession = makeSessionState(); + const resultEvents = captureBridgeEvents(() => { + handleSdkMessage(resultSession, { + type: "result", + subtype: "success", + uuid: "result-1", + session_id: "session-1", + user_message_uuid: "user-message-3", + queued_turn_count: 2, + } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + }); + assert.deepEqual(resultEvents, [ + { + event: "user_message_started", + session_id: "session-1", + message_uuid: "user-message-3", + source: "result", + }, + { + event: "turn_complete", + session_id: "session-1", + queued_turn_count: 2, + }, + ]); +}); + +test("handleSdkMessage does not correlate subagent reply frames", () => { + const session = makeSessionState(); + const events = captureBridgeEvents(() => { + handleSdkMessage(session, { + type: "stream_event", + uuid: "assistant-child", + session_id: "session-1", + parent_tool_use_id: "task-1", + user_message_uuid: "must-not-start", + event: { type: "message_start" }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + handleSdkMessage(session, { + type: "assistant", + uuid: "assistant-child-complete", + session_id: "session-1", + parent_tool_use_id: "task-1", + user_message_uuid: "must-not-start", + message: { role: "assistant", content: [] }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + }); + + assert.equal( + events.some((event) => event.event === "user_message_started"), + false, + ); +}); + +test("SDK envelope diagnostics retain lifecycle metadata without content", () => { + const fields = sdkMessageDiagnosticFields({ + type: "command_lifecycle", + status: "absorbed", + reason: "absorbed_mid_turn", + uuid: "lifecycle-1", + source_uuid: "user-message-1", + parent_tool_use_id: null, + prompt: "do not log this prompt", + content: "do not log this content", + message: { role: "user", content: "do not log this message" }, + }); + + assert.deepEqual(fields, { + sdk_type: "command_lifecycle", + sdk_subtype: undefined, + sdk_uuid: "lifecycle-1", + user_message_uuid: undefined, + parent_tool_use_id: undefined, + parent_tool_use_scope: "root", + request_id: undefined, + origin_kind: undefined, + inner_event_type: undefined, + inner_delta_type: undefined, + lifecycle_status: "absorbed", + lifecycle_state: undefined, + lifecycle_operation: undefined, + lifecycle_reason: "absorbed_mid_turn", + sdk_keys: [ + "content", + "message", + "parent_tool_use_id", + "prompt", + "reason", + "source_uuid", + "status", + "type", + "uuid", + ], + sdk_uuid_fields: { + uuid: "lifecycle-1", + source_uuid: "user-message-1", + }, + }); + assert.equal(JSON.stringify(fields).includes("do not log"), false); +}); + +test("SDK envelope diagnostics omit free-form lifecycle reasons", () => { + const fields = sdkMessageDiagnosticFields({ + type: "future_message", + reason: "user supplied text must stay private", + }); + + assert.equal(fields.lifecycle_reason, undefined); +}); + test("handleSdkMessage emits SDK-owned context Markdown exactly once", () => { const session = makeSessionState(); const events = captureBridgeEvents(() => { @@ -3343,7 +3670,10 @@ test("handleSdkMessage emits SDK-owned context Markdown exactly once", () => { role: "assistant", content: [ { type: "text", text: "## Context usage" }, - { type: "text", text: "| Category | Tokens |\n| --- | ---: |\n| System | 100 |" }, + { + type: "text", + text: "| Category | Tokens |\n| --- | ---: |\n| System | 100 |", + }, ], }, } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); @@ -3372,19 +3702,28 @@ test("handleSdkMessage does not replay ordinary or invalid completed assistant t { type: "assistant", uuid: "ordinary-assistant", - message: { role: "assistant", content: [{ type: "text", text: "already streamed" }] }, + message: { + role: "assistant", + content: [{ type: "text", text: "already streamed" }], + }, }, { type: "assistant", uuid: "malformed-context", context_usage: "invalid", - message: { role: "assistant", content: [{ type: "text", text: "not context" }] }, + message: { + role: "assistant", + content: [{ type: "text", text: "not context" }], + }, }, { type: "assistant", uuid: "empty-context", context_usage: {}, - message: { role: "assistant", content: [{ type: "text", text: " " }] }, + message: { + role: "assistant", + content: [{ type: "text", text: " " }], + }, }, ]) { handleSdkMessage(session, { @@ -6987,12 +7326,14 @@ test("buildPromptUserMessage attributes keyboard text input to a human", () => { { command: "prompt", session_id: "session-1", + message_uuid: "00000000-0000-4000-8000-000000000001", chunks: [{ kind: "text", value: "hello" }], }, "session-1", ), { type: "user", + uuid: "00000000-0000-4000-8000-000000000001", session_id: "session-1", parent_tool_use_id: null, origin: { kind: "human" }, @@ -7009,6 +7350,7 @@ test("buildPromptUserMessage attributes structured keyboard input to a human", ( { command: "prompt", session_id: "session-1", + message_uuid: "00000000-0000-4000-8000-000000000002", chunks: [ { kind: "text", value: "inspect this" }, { @@ -7477,7 +7819,8 @@ test("mapSessionMessagesToUpdates does not synthesize context output from persis type: "system", subtype: "local_command", command: "/context", - content: "## Context usage\n\nThis transcript copy must not become a second response.", + content: + "## Context usage\n\nThis transcript copy must not become a second response.", }, }, ] as unknown as SessionMessage[]); diff --git a/agent-sdk/src/bridge.ts b/agent-sdk/src/bridge.ts index a46e2de5..b0e983cd 100644 --- a/agent-sdk/src/bridge.ts +++ b/agent-sdk/src/bridge.ts @@ -317,6 +317,7 @@ export function buildPromptUserMessage( } return { type: "user", + uuid: command.message_uuid as import("@anthropic-ai/claude-agent-sdk").SDKUserMessage["uuid"], session_id: sessionId, parent_tool_use_id: null, origin: { kind: "human" }, diff --git a/agent-sdk/src/bridge/command_dispatch.ts b/agent-sdk/src/bridge/command_dispatch.ts index 9aa5fabb..09738574 100644 --- a/agent-sdk/src/bridge/command_dispatch.ts +++ b/agent-sdk/src/bridge/command_dispatch.ts @@ -1,4 +1,5 @@ import type { BridgeCommand } from "../types.js"; +import { writeEvent } from "./events.js"; import { bridgeLogger, LOG_TARGETS } from "./logger.js"; type CancelTurnCommand = Extract; @@ -34,6 +35,14 @@ export async function dispatchCancelTurnCommand( (entry): entry is string => typeof entry === "string", ) : []; + writeEvent( + { + event: "turn_interrupt_receipt", + session_id: command.session_id, + still_queued: stillQueued, + }, + deps.requestId, + ); if (stillQueued.length > 0) { bridgeLogger.info({ target: LOG_TARGETS.APP_SESSION, diff --git a/agent-sdk/src/bridge/command_session_control.ts b/agent-sdk/src/bridge/command_session_control.ts index 5112b826..5376b5b3 100644 --- a/agent-sdk/src/bridge/command_session_control.ts +++ b/agent-sdk/src/bridge/command_session_control.ts @@ -9,7 +9,7 @@ import { } from "./commands.js"; import { dispatchCancelTurnCommand } from "./command_dispatch.js"; import { emitFastModeUpdate } from "./error_classification.js"; -import { emitSessionUpdate, slashError } from "./events.js"; +import { emitSessionUpdate, slashError, writeEvent } from "./events.js"; import { bridgeLogger, LOG_TARGETS } from "./logger.js"; import { emitCurrentModelUpdate, @@ -102,12 +102,50 @@ function handlePrompt( ): void { const session = requireSession(command.session_id, requestId); if (!session) { + writeEvent( + { + event: "user_message_rejected", + session_id: command.session_id, + message_uuid: command.message_uuid, + reason: "unknown session", + }, + requestId, + ); return; } const message = deps.buildPromptUserMessage(command, session.sessionId); - if (message) { - session.input.enqueue(message); + if (!message) { + writeEvent( + { + event: "user_message_rejected", + session_id: session.sessionId, + message_uuid: command.message_uuid, + reason: "prompt contained no supported content", + }, + requestId, + ); + return; } + if (!session.input.enqueue(message)) { + writeEvent( + { + event: "user_message_rejected", + session_id: session.sessionId, + message_uuid: command.message_uuid, + reason: "session input is closed", + }, + requestId, + ); + return; + } + writeEvent( + { + event: "user_message_queued", + session_id: session.sessionId, + message_uuid: command.message_uuid, + }, + requestId, + ); } async function setModel( diff --git a/agent-sdk/src/bridge/commands.ts b/agent-sdk/src/bridge/commands.ts index b4aa25e0..14eabc49 100644 --- a/agent-sdk/src/bridge/commands.ts +++ b/agent-sdk/src/bridge/commands.ts @@ -408,6 +408,7 @@ export function parseCommandEnvelope(line: string): { return { command: "prompt", session_id: expectString(raw, "session_id", "prompt"), + message_uuid: expectString(raw, "message_uuid", "prompt"), chunks: parsePromptChunks(raw, "prompt"), }; case "cancel_turn": diff --git a/agent-sdk/src/bridge/logger.ts b/agent-sdk/src/bridge/logger.ts index f4cc485b..08a1a137 100644 --- a/agent-sdk/src/bridge/logger.ts +++ b/agent-sdk/src/bridge/logger.ts @@ -183,6 +183,10 @@ function eventToolCallId(event: BridgeEvent): string | undefined { case "mcp_auth_redirect": case "mcp_operation_error": case "mcp_set_servers_result": + case "user_message_queued": + case "user_message_started": + case "user_message_rejected": + case "turn_interrupt_receipt": case "turn_complete": case "turn_error": case "slash_error": @@ -238,6 +242,7 @@ function protocolEventLevel(event: BridgeEvent): LogLevel { case "elicitation_request": case "elicitation_complete": case "mcp_auth_redirect": + case "user_message_started": case "turn_complete": return "trace"; case "sessions_listed": @@ -248,6 +253,9 @@ function protocolEventLevel(event: BridgeEvent): LogLevel { case "rewind_result": case "runtime_reload_completed": case "mcp_set_servers_result": + case "user_message_queued": + case "user_message_rejected": + case "turn_interrupt_receipt": case "mcp_snapshot": return "debug"; } diff --git a/agent-sdk/src/bridge/message_handlers.ts b/agent-sdk/src/bridge/message_handlers.ts index a3dab942..c231b096 100644 --- a/agent-sdk/src/bridge/message_handlers.ts +++ b/agent-sdk/src/bridge/message_handlers.ts @@ -210,6 +210,54 @@ function sdkMessageOriginKind( return typeof origin?.kind === "string" ? origin.kind : undefined; } +function diagnosticToken(value: unknown): string | undefined { + return typeof value === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(value) + ? value + : undefined; +} + +export function sdkMessageDiagnosticFields( + msg: Record, +): Record { + const event = asRecordOrNull(msg.event); + const delta = asRecordOrNull(event?.delta); + const uuidFields = Object.fromEntries( + Object.entries(msg).filter( + ([key, value]) => + key.toLowerCase().includes("uuid") && typeof value === "string", + ), + ); + const hasParentToolUseId = Object.hasOwn(msg, "parent_tool_use_id"); + const parentToolUseId = trimmedStringField(msg, "parent_tool_use_id"); + + return { + sdk_type: typeof msg.type === "string" ? msg.type : undefined, + sdk_subtype: typeof msg.subtype === "string" ? msg.subtype : undefined, + sdk_uuid: trimmedStringField(msg, "uuid"), + user_message_uuid: trimmedStringField(msg, "user_message_uuid"), + parent_tool_use_id: parentToolUseId, + parent_tool_use_scope: !hasParentToolUseId + ? "absent" + : msg.parent_tool_use_id === null + ? "root" + : parentToolUseId + ? "child" + : "other", + request_id: trimmedStringField(msg, "request_id"), + origin_kind: sdkMessageOriginKind(msg), + inner_event_type: diagnosticToken(event?.type), + inner_delta_type: diagnosticToken(delta?.type), + lifecycle_status: diagnosticToken(msg.status), + lifecycle_state: diagnosticToken(msg.state), + lifecycle_operation: diagnosticToken(msg.operation), + lifecycle_reason: diagnosticToken(msg.reason), + sdk_keys: Object.keys(msg).sort(), + ...(Object.keys(uuidFields).length > 0 + ? { sdk_uuid_fields: uuidFields } + : {}), + }; +} + function externalMessageUpdateFromSdkUser( msg: Record, ): Extract | undefined { @@ -1220,6 +1268,54 @@ export function handleAssistantMessage( } } +function emitUserMessageStarted( + session: SessionState, + message: Record, + source: "stream_event" | "assistant" | "result", +): void { + const messageUuid = trimmedStringField(message, "user_message_uuid"); + if (!messageUuid) { + return; + } + emitUserMessageStartedForUuid(session, messageUuid, source); +} + +function emitUserMessageStartedForUuid( + session: SessionState, + messageUuid: string, + source: "command_lifecycle" | "stream_event" | "assistant" | "result", +): void { + writeEvent({ + event: "user_message_started", + session_id: session.sessionId, + message_uuid: messageUuid, + source, + }); +} + +function handleCommandLifecycleMessage( + session: SessionState, + message: Record, +): void { + const state = trimmedStringField(message, "state"); + const commandUuid = trimmedStringField(message, "command_uuid"); + if (state !== "started") { + return; + } + if (!commandUuid) { + bridgeLogger.warn({ + target: LOG_TARGETS.BRIDGE_SDK, + eventName: "sdk_command_lifecycle_start_invalid", + message: "SDK command lifecycle start omitted its command UUID", + outcome: "ignored", + sessionId: session.sessionId, + fields: sdkMessageDiagnosticFields(message), + }); + return; + } + emitUserMessageStartedForUuid(session, commandUuid, "command_lifecycle"); +} + function messageToolUseResult(message: Record): unknown { if (Object.hasOwn(message, "toolUseResult")) { return message.toolUseResult; @@ -1294,12 +1390,19 @@ export function handleResultMessage( session: SessionState, message: Record, ): void { + if ( + message.parent_tool_use_id === null || + message.parent_tool_use_id === undefined + ) { + emitUserMessageStarted(session, message, "result"); + } emitFastModeUpdateIfChanged( session, message.fast_mode_state, message.fast_mode_disabled_reason, ); const terminalReason = terminalReasonFromValue(message.terminal_reason); + const queuedTurnCount = nonNegativeIntegerField(message, "queued_turn_count"); const subtype = typeof message.subtype === "string" ? message.subtype : ""; if (subtype === "success") { @@ -1308,6 +1411,9 @@ export function handleResultMessage( writeEvent({ event: "turn_complete", session_id: session.sessionId, + ...(queuedTurnCount !== undefined + ? { queued_turn_count: queuedTurnCount } + : {}), ...(terminalReason ? { terminal_reason: terminalReason } : {}), }); return; @@ -1363,6 +1469,9 @@ export function handleResultMessage( event: "turn_error", session_id: session.sessionId, message: errors.length > 0 ? errors.join("\n") : fallback, + ...(queuedTurnCount !== undefined + ? { queued_turn_count: queuedTurnCount } + : {}), error_kind: errorKind, ...(subtype ? { sdk_result_subtype: subtype } : {}), ...(assistantError ? { assistant_error: assistantError } : {}), @@ -1848,6 +1957,12 @@ export function handleSdkMessage( } if (type === "stream_event") { + if ( + msg.parent_tool_use_id === null || + msg.parent_tool_use_id === undefined + ) { + emitUserMessageStarted(session, msg, "stream_event"); + } if (msg.event && typeof msg.event === "object") { const parentToolUseId = typeof msg.parent_tool_use_id === "string" @@ -2066,11 +2181,34 @@ export function handleSdkMessage( if (msg.error === "authentication_failed") { emitAuthRequired(session); } + if ( + msg.parent_tool_use_id === null || + msg.parent_tool_use_id === undefined + ) { + emitUserMessageStarted(session, msg, "assistant"); + } handleAssistantMessage(session, msg); return; } if (type === "result") { handleResultMessage(session, msg); + return; + } + + // Bundled Claude Code 2.1.258 emits this runtime frame even though the + // corresponding SDK 0.3.258 TypeScript union does not declare it. + if (type === "command_lifecycle") { + handleCommandLifecycleMessage(session, msg); + return; } + + bridgeLogger.debug({ + target: LOG_TARGETS.BRIDGE_SDK, + eventName: "sdk_message_unhandled", + message: "SDK message ignored by explicit top-level fallback policy", + outcome: "ignored", + sessionId: session.sessionId, + fields: sdkMessageDiagnosticFields(msg), + }); } diff --git a/agent-sdk/src/bridge/session_lifecycle.ts b/agent-sdk/src/bridge/session_lifecycle.ts index 37bd40a9..f98cf1d2 100644 --- a/agent-sdk/src/bridge/session_lifecycle.ts +++ b/agent-sdk/src/bridge/session_lifecycle.ts @@ -628,6 +628,9 @@ export async function createSession(params: { history_update_count: historyUpdateCount, stale_session_count: staleSessionCount, stale_session_before_register_count: staleSessionBeforeRegisterCount, + sdk_debug_enabled: enableSdkDebug, + sdk_debug_file_configured: Boolean(sdkDebugFile), + sdk_spawn_debug_enabled: enableSpawnDebug, }, }); try { diff --git a/agent-sdk/src/bridge/shared.ts b/agent-sdk/src/bridge/shared.ts index b2cbd2c1..07b1ad32 100644 --- a/agent-sdk/src/bridge/shared.ts +++ b/agent-sdk/src/bridge/shared.ts @@ -11,16 +11,17 @@ export class AsyncQueue implements AsyncIterable { private readonly waiters: Array<(result: IteratorResult) => void> = []; private closed = false; - enqueue(item: T): void { + enqueue(item: T): boolean { if (this.closed) { - return; + return false; } const waiter = this.waiters.shift(); if (waiter) { waiter({ value: item, done: false }); - return; + return true; } this.items.push(item); + return true; } close(): void { diff --git a/agent-sdk/src/types.ts b/agent-sdk/src/types.ts index 2f666bc6..be0d49d9 100644 --- a/agent-sdk/src/types.ts +++ b/agent-sdk/src/types.ts @@ -788,6 +788,7 @@ export type BridgeCommand = | { command: "prompt"; session_id: string; + message_uuid: string; chunks: PromptChunk[]; } | { @@ -1004,15 +1005,39 @@ export type BridgeEvent = session_id: string; result: McpSetServersResult; } + | { + event: "user_message_queued"; + session_id: string; + message_uuid: string; + } + | { + event: "user_message_started"; + session_id: string; + message_uuid: string; + source: "command_lifecycle" | "stream_event" | "assistant" | "result"; + } + | { + event: "user_message_rejected"; + session_id: string; + message_uuid: string; + reason: string; + } + | { + event: "turn_interrupt_receipt"; + session_id: string; + still_queued: string[]; + } | { event: "turn_complete"; session_id: string; + queued_turn_count?: number; terminal_reason?: TerminalReason; } | { event: "turn_error"; session_id: string; message: string; + queued_turn_count?: number; error_kind?: TurnErrorKind; sdk_result_subtype?: string; assistant_error?: ApiRetryError; diff --git a/src/agent/client.rs b/src/agent/client.rs index fd8c4e12..3b7d7b39 100644 --- a/src/agent/client.rs +++ b/src/agent/client.rs @@ -576,12 +576,13 @@ impl AgentConnection { /// Convenience wrapper for text-only prompts. Prefer `prompt_with_images` /// for new call sites that may need image support. pub fn prompt_text(&self, session_id: String, text: String) -> anyhow::Result { - self.prompt_with_images(session_id, text, Vec::new()) + self.prompt_with_images(session_id, uuid::Uuid::new_v4().to_string(), text, Vec::new()) } pub fn prompt_with_images( &self, session_id: String, + message_uuid: String, text: String, images: Vec, ) -> anyhow::Result { @@ -612,7 +613,7 @@ impl AgentConnection { self.send(CommandEnvelope { request_id: None, - command: BridgeCommand::Prompt { session_id, chunks }, + command: BridgeCommand::Prompt { session_id, message_uuid, chunks }, })?; Ok(PromptResponse { stop_reason: "end_turn".to_owned() }) } diff --git a/src/agent/events.rs b/src/agent/events.rs index 47210447..37a9ac04 100644 --- a/src/agent/events.rs +++ b/src/agent/events.rs @@ -48,6 +48,18 @@ pub enum ClientEvent { McpOperationError { session_id: String, error: crate::agent::types::McpOperationError }, /// Dynamic MCP server replacement completed through the SDK. McpSetServersResult { session_id: String, result: crate::agent::types::McpSetServersResult }, + /// The bridge accepted a UUID-stamped prompt into its local SDK input iterable. + UserMessageQueued { session_id: String, message_uuid: String }, + /// The SDK correlated command start, a main-thread reply, or a result with a prompt UUID. + UserMessageStarted { + session_id: String, + message_uuid: String, + source: crate::agent::types::UserMessageStartSource, + }, + /// The bridge proved that a submitted prompt cannot enter the SDK input stream. + UserMessageRejected { session_id: String, message_uuid: String, reason: String }, + /// Snapshot of UUID-stamped messages that will survive an interrupt. + TurnInterruptReceipt { session_id: String, still_queued: Vec }, /// Claude CLI removed an MCP server from a persisted config scope. McpConfigRemoveSucceeded { cwd_raw: String, @@ -60,12 +72,14 @@ pub enum ClientEvent { /// A prompt turn completed successfully. TurnComplete { session_id: String, + queued_turn_count: Option, terminal_reason: Option, }, /// A prompt turn failed with an error. TurnError { session_id: String, message: String, + queued_turn_count: Option, api_error_status: Option, terminal_reason: Option, }, @@ -74,6 +88,7 @@ pub enum ClientEvent { session_id: String, message: String, class: TurnErrorClass, + queued_turn_count: Option, api_error_status: Option, terminal_reason: Option, }, @@ -191,6 +206,10 @@ impl ClientEvent { | Self::McpAuthRedirect { session_id, .. } | Self::McpOperationError { session_id, .. } | Self::McpSetServersResult { session_id, .. } + | Self::UserMessageQueued { session_id, .. } + | Self::UserMessageStarted { session_id, .. } + | Self::UserMessageRejected { session_id, .. } + | Self::TurnInterruptReceipt { session_id, .. } | Self::TurnComplete { session_id, .. } | Self::TurnError { session_id, .. } | Self::TurnErrorClassified { session_id, .. } diff --git a/src/agent/types.rs b/src/agent/types.rs index 1d677a63..8721a31f 100644 --- a/src/agent/types.rs +++ b/src/agent/types.rs @@ -935,6 +935,15 @@ pub struct PromptChunk { pub value: serde_json::Value, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UserMessageStartSource { + CommandLifecycle, + StreamEvent, + Assistant, + Result, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct AccountInfo { pub email: Option, diff --git a/src/agent/wire.rs b/src/agent/wire.rs index 3902bd59..001075a3 100644 --- a/src/agent/wire.rs +++ b/src/agent/wire.rs @@ -63,6 +63,7 @@ pub enum BridgeCommand { }, Prompt { session_id: String, + message_uuid: String, chunks: Vec, }, CancelTurn { @@ -358,14 +359,36 @@ pub enum BridgeEvent { session_id: String, result: types::McpSetServersResult, }, + UserMessageQueued { + session_id: String, + message_uuid: String, + }, + UserMessageStarted { + session_id: String, + message_uuid: String, + source: types::UserMessageStartSource, + }, + UserMessageRejected { + session_id: String, + message_uuid: String, + reason: String, + }, + TurnInterruptReceipt { + session_id: String, + still_queued: Vec, + }, TurnComplete { session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] + queued_turn_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] terminal_reason: Option, }, TurnError { session_id: String, message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + queued_turn_count: Option, error_kind: Option, sdk_result_subtype: Option, assistant_error: Option, @@ -461,6 +484,10 @@ impl BridgeEvent { Self::McpAuthRedirect { .. } => "mcp_auth_redirect", Self::McpOperationError { .. } => "mcp_operation_error", Self::McpSetServersResult { .. } => "mcp_set_servers_result", + Self::UserMessageQueued { .. } => "user_message_queued", + Self::UserMessageStarted { .. } => "user_message_started", + Self::UserMessageRejected { .. } => "user_message_rejected", + Self::TurnInterruptReceipt { .. } => "turn_interrupt_receipt", Self::TurnComplete { .. } => "turn_complete", Self::TurnError { .. } => "turn_error", Self::SlashError { .. } => "slash_error", @@ -492,6 +519,10 @@ impl BridgeEvent { | Self::McpAuthRedirect { session_id, .. } | Self::McpOperationError { session_id, .. } | Self::McpSetServersResult { session_id, .. } + | Self::UserMessageQueued { session_id, .. } + | Self::UserMessageStarted { session_id, .. } + | Self::UserMessageRejected { session_id, .. } + | Self::TurnInterruptReceipt { session_id, .. } | Self::TurnComplete { session_id, .. } | Self::TurnError { session_id, .. } | Self::SlashError { session_id, .. } @@ -529,6 +560,10 @@ impl BridgeEvent { | Self::McpAuthRedirect { .. } | Self::McpOperationError { .. } | Self::McpSetServersResult { .. } + | Self::UserMessageQueued { .. } + | Self::UserMessageStarted { .. } + | Self::UserMessageRejected { .. } + | Self::TurnInterruptReceipt { .. } | Self::TurnComplete { .. } | Self::TurnError { .. } | Self::SlashError { .. } @@ -570,6 +605,104 @@ mod tests { assert_eq!(decoded, env); } + #[test] + fn prompt_command_serializes_host_message_uuid() { + let env = CommandEnvelope { + request_id: None, + command: BridgeCommand::Prompt { + session_id: "s1".to_owned(), + message_uuid: "message-1".to_owned(), + chunks: vec![types::PromptChunk { + kind: "text".to_owned(), + value: serde_json::json!("next"), + }], + }, + }; + + assert_eq!( + serde_json::to_value(env).expect("serialize"), + serde_json::json!({ + "command": "prompt", + "session_id": "s1", + "message_uuid": "message-1", + "chunks": [{ "kind": "text", "value": "next" }] + }) + ); + } + + #[test] + fn queued_user_message_lifecycle_events_deserialize() { + let queued: EventEnvelope = serde_json::from_value(serde_json::json!({ + "event": "user_message_queued", + "session_id": "s1", + "message_uuid": "message-1" + })) + .expect("deserialize queued receipt"); + assert_eq!( + queued.event, + BridgeEvent::UserMessageQueued { + session_id: "s1".to_owned(), + message_uuid: "message-1".to_owned(), + } + ); + + let started: EventEnvelope = serde_json::from_value(serde_json::json!({ + "event": "user_message_started", + "session_id": "s1", + "message_uuid": "message-1", + "source": "command_lifecycle" + })) + .expect("deserialize start receipt"); + assert_eq!( + started.event, + BridgeEvent::UserMessageStarted { + session_id: "s1".to_owned(), + message_uuid: "message-1".to_owned(), + source: types::UserMessageStartSource::CommandLifecycle, + } + ); + + let interrupt: EventEnvelope = serde_json::from_value(serde_json::json!({ + "event": "turn_interrupt_receipt", + "session_id": "s1", + "still_queued": ["message-1"] + })) + .expect("deserialize interrupt receipt"); + assert_eq!( + interrupt.event, + BridgeEvent::TurnInterruptReceipt { + session_id: "s1".to_owned(), + still_queued: vec!["message-1".to_owned()], + } + ); + } + + #[test] + fn turn_result_events_deserialize_queued_turn_count() { + let complete: EventEnvelope = serde_json::from_value(serde_json::json!({ + "event": "turn_complete", + "session_id": "s1", + "queued_turn_count": 2 + })) + .expect("deserialize completion count"); + let BridgeEvent::TurnComplete { queued_turn_count, .. } = complete.event else { + panic!("expected completion event"); + }; + assert_eq!(queued_turn_count, Some(2)); + + let error: EventEnvelope = serde_json::from_value(serde_json::json!({ + "event": "turn_error", + "session_id": "s1", + "message": "failed", + "queued_turn_count": 1 + })) + .expect("deserialize error count"); + let BridgeEvent::TurnError { queued_turn_count, .. } = error.event else { + panic!("expected error event"); + }; + assert_eq!(queued_turn_count, Some(1)); + } + #[test] fn session_resume_failed_event_deserializes_with_operation_identity() { let decoded: EventEnvelope = serde_json::from_value(serde_json::json!({ @@ -883,6 +1016,7 @@ mod tests { request_id: None, event: BridgeEvent::TurnComplete { session_id: "session-1".to_owned(), + queued_turn_count: None, terminal_reason: Some(types::TerminalReason::Completed), }, }; diff --git a/src/app/clipboard_image.rs b/src/app/clipboard_image.rs index 74cfc3d2..7d4fc93e 100644 --- a/src/app/clipboard_image.rs +++ b/src/app/clipboard_image.rs @@ -11,7 +11,7 @@ pub const SUPPORTED_IMAGE_MIME_TYPES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; /// A pending image attachment: base64-encoded data and its MIME type. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ImageAttachment { pub data: String, pub mime_type: String, diff --git a/src/app/connect/event_dispatch.rs b/src/app/connect/event_dispatch.rs index fd93cc39..db53a33c 100644 --- a/src/app/connect/event_dispatch.rs +++ b/src/app/connect/event_dispatch.rs @@ -114,12 +114,45 @@ pub(super) async fn handle_bridge_event( crate::agent::wire::BridgeEvent::McpSetServersResult { session_id, result } => { let _ = event_tx.send(ClientEvent::McpSetServersResult { session_id, result }).await; } - crate::agent::wire::BridgeEvent::TurnComplete { session_id, terminal_reason } => { - let _ = event_tx.send(ClientEvent::TurnComplete { session_id, terminal_reason }).await; + crate::agent::wire::BridgeEvent::UserMessageQueued { session_id, message_uuid } => { + let _ = + event_tx.send(ClientEvent::UserMessageQueued { session_id, message_uuid }).await; + } + crate::agent::wire::BridgeEvent::UserMessageStarted { + session_id, + message_uuid, + source, + } => { + let _ = event_tx + .send(ClientEvent::UserMessageStarted { session_id, message_uuid, source }) + .await; + } + crate::agent::wire::BridgeEvent::UserMessageRejected { + session_id, + message_uuid, + reason, + } => { + let _ = event_tx + .send(ClientEvent::UserMessageRejected { session_id, message_uuid, reason }) + .await; + } + crate::agent::wire::BridgeEvent::TurnInterruptReceipt { session_id, still_queued } => { + let _ = + event_tx.send(ClientEvent::TurnInterruptReceipt { session_id, still_queued }).await; + } + crate::agent::wire::BridgeEvent::TurnComplete { + session_id, + queued_turn_count, + terminal_reason, + } => { + let _ = event_tx + .send(ClientEvent::TurnComplete { session_id, queued_turn_count, terminal_reason }) + .await; } crate::agent::wire::BridgeEvent::TurnError { session_id, message, + queued_turn_count, error_kind, api_error_status, terminal_reason, @@ -131,6 +164,7 @@ pub(super) async fn handle_bridge_event( session_id, message, class, + queued_turn_count, api_error_status, terminal_reason, }) @@ -140,6 +174,7 @@ pub(super) async fn handle_bridge_event( .send(ClientEvent::TurnError { session_id, message, + queued_turn_count, api_error_status, terminal_reason, }) diff --git a/src/app/connect/mod.rs b/src/app/connect/mod.rs index 1a572b5a..fbc55b40 100644 --- a/src/app/connect/mod.rs +++ b/src/app/connect/mod.rs @@ -204,6 +204,7 @@ pub fn create_app(cli: &Cli) -> App { pending_submit: None, paste: super::state::PasteState::default(), pending_images: Vec::new(), + pending_user_messages: super::state::PendingUserMessages::default(), git_context: super::git_context::GitContextState::default(), update_prompt, post_exit_action: None, diff --git a/src/app/events/client.rs b/src/app/events/client.rs index 4561b3c3..44f60a60 100644 --- a/src/app/events/client.rs +++ b/src/app/events/client.rs @@ -61,6 +61,10 @@ fn client_event_family(event: &ClientEvent) -> ClientEventFamily { | ClientEvent::PermissionRequest { .. } | ClientEvent::QuestionRequest { .. } | ClientEvent::UserDialogRequest { .. } + | ClientEvent::UserMessageQueued { .. } + | ClientEvent::UserMessageStarted { .. } + | ClientEvent::UserMessageRejected { .. } + | ClientEvent::TurnInterruptReceipt { .. } | ClientEvent::TurnComplete { .. } | ClientEvent::TurnError { .. } | ClientEvent::TurnErrorClassified { .. } diff --git a/src/app/events/client/turn.rs b/src/app/events/client/turn.rs index 811134c8..6ee861ee 100644 --- a/src/app/events/client/turn.rs +++ b/src/app/events/client/turn.rs @@ -17,16 +17,42 @@ pub(super) fn handle(app: &mut App, event: ClientEvent) { ClientEvent::UserDialogRequest { session_id: _, request, response_tx } => { turn::handle_user_dialog_request_event(app, request, response_tx); } - ClientEvent::TurnComplete { session_id: _, terminal_reason } => { - turn::handle_turn_complete_event(app, terminal_reason); + ClientEvent::UserMessageQueued { session_id: _, message_uuid } => { + turn::handle_user_message_queued_event(app, &message_uuid); } - ClientEvent::TurnError { session_id: _, message, api_error_status, terminal_reason } => { - turn::handle_turn_error_event(app, &message, None, api_error_status, terminal_reason); + ClientEvent::UserMessageStarted { session_id: _, message_uuid, source } => { + turn::handle_user_message_started_event(app, &message_uuid, source); + } + ClientEvent::UserMessageRejected { session_id: _, message_uuid, reason } => { + turn::handle_user_message_rejected_event(app, &message_uuid, &reason); + } + ClientEvent::TurnInterruptReceipt { session_id: _, still_queued } => { + turn::handle_turn_interrupt_receipt_event(app, &still_queued); + } + ClientEvent::TurnComplete { session_id: _, queued_turn_count, terminal_reason } => { + turn::handle_turn_complete_event(app, queued_turn_count, terminal_reason); + } + ClientEvent::TurnError { + session_id: _, + message, + queued_turn_count, + api_error_status, + terminal_reason, + } => { + turn::handle_turn_error_event( + app, + &message, + None, + queued_turn_count, + api_error_status, + terminal_reason, + ); } ClientEvent::TurnErrorClassified { session_id: _, message, class, + queued_turn_count, api_error_status, terminal_reason, } => { @@ -34,6 +60,7 @@ pub(super) fn handle(app: &mut App, event: ClientEvent) { app, &message, Some(class), + queued_turn_count, api_error_status, terminal_reason, ); diff --git a/src/app/events/mod.rs b/src/app/events/mod.rs index 669ef252..6aeaa976 100644 --- a/src/app/events/mod.rs +++ b/src/app/events/mod.rs @@ -44,7 +44,7 @@ pub(crate) fn handle_local_cancel_enqueued(app: &mut App) { /// backend events. pub(crate) fn handle_local_prompt_dispatch_error(app: &mut App, message: &str) { app.request_active_surface_repaint(); - turn::handle_turn_error_event(app, message, None, None, None); + turn::handle_turn_error_event(app, message, None, None, None, None); } /// Surface a synchronous slash-command dispatch failure without injecting a diff --git a/src/app/events/session.rs b/src/app/events/session.rs index 3ec35cb6..c06345d0 100644 --- a/src/app/events/session.rs +++ b/src/app/events/session.rs @@ -155,6 +155,7 @@ pub(super) fn handle_auth_required_event( method_name: String, method_description: String, ) { + super::turn::recover_all_pending_user_messages(app); let method_name_for_log = method_name.clone(); clear_pending_command(app); app.status = AppStatus::Ready; @@ -181,6 +182,7 @@ pub(super) fn handle_auth_required_event( } pub(super) fn handle_connection_failed_event(app: &mut App, msg: &str) { + super::turn::recover_all_pending_user_messages(app); app.bump_session_scope_epoch(); app.clear_session_runtime_identity(); super::compaction::reset(app); @@ -666,7 +668,7 @@ fn maybe_open_startup_session_picker(app: &mut App) { mod tests { use super::*; use crate::app::file_index::FileCandidate; - use crate::app::{App, MessageRole}; + use crate::app::{App, MessageRole, PendingUserMessage}; use std::time::{Duration, Instant}; fn wait_for(app: &mut App, timeout: Duration, mut predicate: impl FnMut(&App) -> bool) { @@ -724,6 +726,27 @@ mod tests { ); } + #[test] + fn connection_failure_recovers_pending_user_messages_into_composer() { + let mut app = App::test_default(); + assert!( + app.pending_user_messages + .try_push_sending(PendingUserMessage::sending( + "message-1".to_owned(), + "queued message".to_owned(), + Vec::new(), + )) + .is_ok() + ); + app.input.set_text("newer draft"); + + handle_connection_failed_event(&mut app, "bridge stopped"); + + assert!(app.pending_user_messages.is_empty()); + assert_eq!(app.input.text(), "queued message\nnewer draft"); + assert_eq!(app.status, AppStatus::Error); + } + #[test] fn successful_rewind_with_skips_emits_warning() { let mut app = App::test_default(); diff --git a/src/app/events/session_reset.rs b/src/app/events/session_reset.rs index 5e4ceca7..70d5f42e 100644 --- a/src/app/events/session_reset.rs +++ b/src/app/events/session_reset.rs @@ -48,6 +48,7 @@ fn reset_session_identity_state( mode: Option, fast_mode: model::FastModeSnapshot, ) { + app.pending_user_messages.clear(); app.bump_session_scope_epoch(); app.session_runtime.activate_session(session_id); app.session_runtime.current_model = Some(current_model.clone()); @@ -194,7 +195,7 @@ pub(super) fn load_resume_history(app: &mut App, history_updates: &[model::Sessi mod tests { use super::{ChatResetKind, reset_for_new_session}; use crate::agent::model; - use crate::app::{App, ChatMessage, ChatRebuildKind}; + use crate::app::{App, ChatMessage, ChatRebuildKind, PendingUserMessage}; #[test] fn session_reset_clears_chat_render_measurement_state() { @@ -227,6 +228,33 @@ mod tests { assert_eq!(app.chat_render.live_region.last_rendered_rows, 0); } + #[test] + fn replacement_session_reset_discards_old_session_pending_projection() { + let mut app = App::test_default(); + assert!( + app.pending_user_messages + .try_push_sending(PendingUserMessage::sending( + "old-session-message".to_owned(), + "must not cross sessions".to_owned(), + Vec::new(), + )) + .is_ok() + ); + app.input.set_text("old draft"); + + reset_for_new_session( + &mut app, + model::SessionId::new("session-2"), + model::CurrentModel::new("test", "test", "test").authoritative(true), + None, + model::FastModeSnapshot::new(model::FastModeState::Off, None), + ChatResetKind::Replacement, + ); + + assert!(app.pending_user_messages.is_empty()); + assert!(app.input.text().is_empty()); + } + #[test] fn startup_session_reset_preserves_inline_viewport_for_diffed_repaint() { let mut app = App::test_default(); diff --git a/src/app/events/tests.rs b/src/app/events/tests.rs index 12370cfc..18535406 100644 --- a/src/app/events/tests.rs +++ b/src/app/events/tests.rs @@ -26,7 +26,11 @@ fn session_update(update: model::SessionUpdate) -> ClientEvent { } fn turn_complete(terminal_reason: Option) -> ClientEvent { - ClientEvent::TurnComplete { session_id: "test-session".to_owned(), terminal_reason } + ClientEvent::TurnComplete { + session_id: "test-session".to_owned(), + queued_turn_count: None, + terminal_reason, + } } fn slash_command_error(message: String) -> ClientEvent { @@ -1652,6 +1656,7 @@ fn turn_error_clears_manual_compaction_without_success_message() { ClientEvent::TurnError { session_id: "test-session".to_owned(), message: "adapter failed".into(), + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, @@ -1695,6 +1700,7 @@ fn turn_error_after_cancel_clears_compaction_without_success() { ClientEvent::TurnError { session_id: "test-session".to_owned(), message: "cancelled".into(), + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, @@ -1717,6 +1723,7 @@ fn turn_error_plan_limit_shows_next_steps_guidance() { ClientEvent::TurnError { session_id: "test-session".to_owned(), message: "HTTP 429 Too Many Requests: max turns exceeded".into(), + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, @@ -1745,6 +1752,7 @@ fn classified_turn_error_plan_limit_uses_guidance_without_text_matching() { session_id: "test-session".to_owned(), message: "turn failed".into(), class: TurnErrorClass::PlanLimit, + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, @@ -1772,6 +1780,7 @@ fn classified_turn_error_auth_required_sets_exit_error_and_quits() { session_id: "test-session".to_owned(), message: "auth required".into(), class: TurnErrorClass::AuthRequired, + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, @@ -1792,6 +1801,7 @@ fn classified_turn_error_model_unavailable_suggests_model_switch() { session_id: "test-session".to_owned(), message: "model_not_found".into(), class: TurnErrorClass::ModelUnavailable, + queued_turn_count: None, api_error_status: Some(404), terminal_reason: None, }, @@ -1815,6 +1825,7 @@ fn classified_turn_error_account_access_does_not_quit_for_login() { session_id: "test-session".to_owned(), message: "oauth_org_not_allowed".into(), class: TurnErrorClass::AccountAccess, + queued_turn_count: None, api_error_status: Some(403), terminal_reason: None, }, @@ -1838,6 +1849,7 @@ fn classified_turn_error_transient_service_suggests_retry() { session_id: "test-session".to_owned(), message: "overloaded".into(), class: TurnErrorClass::TransientService, + queued_turn_count: None, api_error_status: Some(529), terminal_reason: None, }, @@ -1865,6 +1877,7 @@ fn turn_error_clears_tool_scope_tracking() { ClientEvent::TurnError { session_id: "test-session".to_owned(), message: "boom".into(), + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, @@ -2303,6 +2316,7 @@ fn plan_limit_turn_error_upgrades_inline_notice_in_active_assistant() { session_id: "test-session".to_owned(), message: "HTTP 429 Too Many Requests".to_owned(), class: TurnErrorClass::PlanLimit, + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, @@ -2348,6 +2362,7 @@ fn different_rate_limit_incident_in_later_turn_keeps_older_notice() { session_id: "test-session".to_owned(), message: "HTTP 429 Too Many Requests".to_owned(), class: TurnErrorClass::PlanLimit, + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, @@ -2476,6 +2491,7 @@ fn turn_error_after_cancel_shows_interrupted_hint_instead_of_error_block() { ClientEvent::TurnError { session_id: "test-session".to_owned(), message: "Error: Request was aborted.\n at stack line".into(), + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, diff --git a/src/app/events/tests/client_events.rs b/src/app/events/tests/client_events.rs index 51611764..adbe2f0a 100644 --- a/src/app/events/tests/client_events.rs +++ b/src/app/events/tests/client_events.rs @@ -756,7 +756,11 @@ fn stale_turn_completion_cannot_finish_current_turn() { handle_client_event( &mut app, - ClientEvent::TurnComplete { session_id: "old-session".to_owned(), terminal_reason: None }, + ClientEvent::TurnComplete { + session_id: "old-session".to_owned(), + queued_turn_count: None, + terminal_reason: None, + }, ); assert!(matches!(app.status, AppStatus::Running)); diff --git a/src/app/events/turn.rs b/src/app/events/turn.rs index bb8ec793..008c0590 100644 --- a/src/app/events/turn.rs +++ b/src/app/events/turn.rs @@ -450,7 +450,7 @@ fn finish_ready_turn_exit(app: &mut App, exit: TurnExitState, tool_status: model app.sync_git_context(); let removed_tail_assistant = remove_empty_tail_assistant(app, exit.tail_assistant_idx); - if exit.cancel_requested { + if exit.cancel_requested && app.pending_user_messages.is_empty() { push_interrupted_hint(app); } if removed_tail_assistant.is_none() && (exit.turn_was_active || exit.cancel_requested) { @@ -461,8 +461,10 @@ fn finish_ready_turn_exit(app: &mut App, exit: TurnExitState, tool_status: model pub(super) fn handle_turn_complete_event( app: &mut App, + queued_turn_count: Option, terminal_reason: Option, ) { + log_queued_turn_count(app, queued_turn_count, "success"); let exit = begin_turn_exit(app, true); let turn_was_active = exit.turn_was_active; if let Some(reason) = terminal_reason { @@ -494,9 +496,11 @@ pub(super) fn handle_turn_error_event( app: &mut App, msg: &str, classified: Option, + queued_turn_count: Option, api_error_status: Option, terminal_reason: Option, ) { + log_queued_turn_count(app, queued_turn_count, "error"); let exit = begin_turn_exit(app, false); if exit.cancel_requested { @@ -552,6 +556,129 @@ pub(super) fn handle_turn_error_event( crate::app::session_runtime::request_context_usage_refresh(app); } +pub(super) fn handle_user_message_queued_event(app: &mut App, message_uuid: &str) { + let transitioned = app.pending_user_messages.mark_queued(message_uuid); + tracing::debug!( + target: crate::logging::targets::APP_INPUT, + event_name = "user_message_locally_accepted", + message = "bridge accepted user message into the SDK input path", + outcome = if transitioned { "queued" } else { "ignored" }, + message_uuid, + pending_message_count = app.pending_user_messages.len(), + ); +} + +pub(super) fn handle_user_message_started_event( + app: &mut App, + message_uuid: &str, + source: crate::agent::types::UserMessageStartSource, +) { + let started = app.pending_user_messages.take_started_prefix(message_uuid); + if started.is_empty() { + tracing::debug!( + target: crate::logging::targets::APP_INPUT, + event_name = "user_message_start_ignored", + message = "user message start acknowledgement did not match a pending host message", + outcome = "ignored", + message_uuid, + source = ?source, + ); + return; + } + + let coalesced_message_count = started.len(); + for message in started { + app.push_message_tracked(ChatMessage::new( + MessageRole::User, + vec![MessageBlock::Text(TextBlock::from_complete(&message.text))], + None, + )); + app.push_message_tracked(ChatMessage::new(MessageRole::Assistant, Vec::new(), None)); + app.bind_active_turn_assistant_to_tail(); + app.status = AppStatus::Thinking; + app.enforce_history_retention_tracked(); + } + tracing::info!( + target: crate::logging::targets::APP_INPUT, + event_name = "user_message_started", + message = "SDK began a correlated pending user turn", + outcome = "started", + message_uuid, + source = ?source, + coalesced_message_count, + pending_message_count = app.pending_user_messages.len(), + ); +} + +pub(super) fn handle_user_message_rejected_event(app: &mut App, message_uuid: &str, reason: &str) { + let Some(message) = app.pending_user_messages.remove(message_uuid) else { + tracing::debug!( + target: crate::logging::targets::APP_INPUT, + event_name = "user_message_rejection_ignored", + message = "user message rejection did not match a pending host message", + outcome = "ignored", + message_uuid, + reason, + ); + return; + }; + restore_pending_user_messages(app, vec![message]); + super::notices::emit_system_notice( + app, + SystemSeverity::Error, + &format!("Queued message was not accepted and has been restored: {reason}"), + ); +} + +pub(super) fn handle_turn_interrupt_receipt_event(app: &mut App, still_queued: &[String]) { + let matched = app.pending_user_messages.reconcile_interrupt_survivors(still_queued); + tracing::info!( + target: crate::logging::targets::APP_INPUT, + event_name = "turn_interrupt_queue_reconciled", + message = "interrupt receipt reconciled queued user messages", + outcome = "success", + receipt_message_count = still_queued.len(), + matched_message_count = matched, + pending_message_count = app.pending_user_messages.len(), + ); +} + +pub(super) fn recover_all_pending_user_messages(app: &mut App) { + let messages = app.pending_user_messages.drain(); + restore_pending_user_messages(app, messages); +} + +fn restore_pending_user_messages(app: &mut App, messages: Vec) { + if messages.is_empty() { + return; + } + let current_text = app.input.text(); + let mut text_parts: Vec = messages.iter().map(|message| message.text.clone()).collect(); + if !current_text.trim().is_empty() { + text_parts.push(current_text); + } + let mut images = messages.into_iter().flat_map(|message| message.images).collect::>(); + images.append(&mut app.pending_images); + app.input.set_text(&text_parts.join("\n")); + app.pending_images = images; + app.input.renumber_image_badges(); + app.request_active_surface_repaint(); +} + +fn log_queued_turn_count(app: &App, queued_turn_count: Option, outcome: &str) { + let Some(queued_turn_count) = queued_turn_count else { + return; + }; + tracing::debug!( + target: crate::logging::targets::APP_INPUT, + event_name = "sdk_queued_turn_count_observed", + message = "SDK result reported its queued user-send count", + outcome, + queued_turn_count, + host_pending_message_count = app.pending_user_messages.len(), + ); +} + fn apply_turn_error_class_side_effects( app: &mut App, error_class: TurnErrorClass, @@ -772,6 +899,28 @@ mod tests { ) } + fn pending_message( + uuid: &str, + text: &str, + images: Vec, + ) -> crate::app::PendingUserMessage { + crate::app::PendingUserMessage::sending(uuid.to_owned(), text.to_owned(), images) + } + + fn image(data: &str) -> crate::app::clipboard_image::ImageAttachment { + crate::app::clipboard_image::ImageAttachment { + data: data.to_owned(), + mime_type: "image/png".to_owned(), + } + } + + fn message_text(message: &ChatMessage) -> Option<&str> { + let MessageBlock::Text(text) = message.blocks.first()? else { + return None; + }; + Some(text.text.as_str()) + } + fn tool_call_info( id: &str, title: &str, @@ -870,12 +1019,159 @@ mod tests { app.transcript.messages.push(user_message("hello")); app.transcript.messages.push(empty_assistant_message()); - handle_turn_complete_event(&mut app, None); + handle_turn_complete_event(&mut app, None, None); assert_eq!(app.transcript.messages.len(), 1); assert!(matches!(app.transcript.messages[0].role, MessageRole::User)); } + #[test] + fn queued_receipt_is_uuid_keyed_and_idempotent() { + let mut app = App::test_default(); + assert!( + app.pending_user_messages + .try_push_sending(pending_message("one", "first", Vec::new())) + .is_ok() + ); + + handle_user_message_queued_event(&mut app, "one"); + handle_user_message_queued_event(&mut app, "one"); + handle_user_message_queued_event(&mut app, "unknown"); + + assert!(!app.pending_user_messages.mark_queued("one")); + assert_eq!(app.pending_user_messages.len(), 1); + } + + #[test] + fn correlated_start_commits_each_message_in_the_coalesced_prefix_once() { + let mut app = App::test_default(); + assert!( + app.pending_user_messages + .try_push_sending(pending_message("one", "first", Vec::new())) + .is_ok() + ); + assert!( + app.pending_user_messages + .try_push_sending(pending_message("two", "second", Vec::new())) + .is_ok() + ); + assert!( + app.pending_user_messages + .try_push_sending(pending_message("three", "third", Vec::new())) + .is_ok() + ); + + handle_user_message_started_event( + &mut app, + "two", + crate::agent::types::UserMessageStartSource::CommandLifecycle, + ); + + assert_eq!(app.pending_user_messages.len(), 1); + assert_eq!( + app.pending_user_messages.iter().next().map(|message| message.uuid.as_str()), + Some("three") + ); + assert_eq!(app.transcript.messages.len(), 4); + assert!(matches!(app.transcript.messages[0].role, MessageRole::User)); + assert_eq!(message_text(&app.transcript.messages[0]), Some("first")); + assert!(matches!(app.transcript.messages[1].role, MessageRole::Assistant)); + assert_eq!(message_text(&app.transcript.messages[2]), Some("second")); + assert!(matches!(app.transcript.messages[3].role, MessageRole::Assistant)); + assert_eq!(app.active_turn_assistant_idx(), Some(3)); + assert_eq!(app.status, AppStatus::Thinking); + + handle_user_message_started_event( + &mut app, + "two", + crate::agent::types::UserMessageStartSource::Result, + ); + handle_user_message_started_event( + &mut app, + "unknown", + crate::agent::types::UserMessageStartSource::Assistant, + ); + assert_eq!( + app.transcript.messages.len(), + 4, + "duplicate and unknown acknowledgements must be inert" + ); + assert_eq!(app.pending_user_messages.len(), 1); + } + + #[test] + fn rejected_message_restores_ordered_payload_before_newer_draft() { + let mut app = App::test_default(); + assert!( + app.pending_user_messages + .try_push_sending(pending_message( + "one", + "old [Image #7]", + vec![image("old-image")], + )) + .is_ok() + ); + app.input.set_text("new [Image #4]"); + app.pending_images.push(image("new-image")); + + handle_user_message_rejected_event(&mut app, "one", "session input is closed"); + + assert!(app.pending_user_messages.is_empty()); + assert_eq!(app.input.text(), "old [Image #1]\nnew [Image #2]"); + assert_eq!(app.pending_images, vec![image("old-image"), image("new-image")]); + let Some(MessageBlock::Notice(notice)) = + app.transcript.messages.last().and_then(|message| message.blocks.last()) + else { + panic!("expected rejection notice"); + }; + assert!(notice.text.text.contains("has been restored")); + assert!(notice.text.text.contains("session input is closed")); + } + + #[test] + fn interrupt_receipt_marks_only_known_survivors_without_evicting_pending_messages() { + let mut app = App::test_default(); + assert!( + app.pending_user_messages + .try_push_sending(pending_message("one", "first", Vec::new())) + .is_ok() + ); + assert!( + app.pending_user_messages + .try_push_sending(pending_message("two", "second", Vec::new())) + .is_ok() + ); + + handle_turn_interrupt_receipt_event(&mut app, &["two".to_owned(), "unknown".to_owned()]); + + assert_eq!(app.pending_user_messages.len(), 2); + assert!(app.pending_user_messages.mark_queued("one")); + assert!(!app.pending_user_messages.mark_queued("two")); + } + + #[test] + fn recovering_all_pending_messages_preserves_fifo_text_and_image_order() { + let mut app = App::test_default(); + assert!( + app.pending_user_messages + .try_push_sending(pending_message("one", "first [Image #9]", vec![image("one")],)) + .is_ok() + ); + assert!( + app.pending_user_messages + .try_push_sending(pending_message("two", "second [Image #8]", vec![image("two")],)) + .is_ok() + ); + app.input.set_text("draft [Image #3]"); + app.pending_images.push(image("draft")); + + recover_all_pending_user_messages(&mut app); + + assert!(app.pending_user_messages.is_empty()); + assert_eq!(app.input.text(), "first [Image #1]\nsecond [Image #2]\ndraft [Image #3]"); + assert_eq!(app.pending_images, vec![image("one"), image("two"), image("draft")]); + } + #[test] fn cancelled_turn_error_removes_empty_tail_assistant_before_hint() { let mut app = App::test_default(); @@ -884,7 +1180,7 @@ mod tests { app.transcript.messages.push(user_message("hello")); app.transcript.messages.push(empty_assistant_message()); - handle_turn_error_event(&mut app, "cancelled", None, None, None); + handle_turn_error_event(&mut app, "cancelled", None, None, None, None); assert_eq!(app.transcript.messages.len(), 2); assert!(matches!(app.transcript.messages[0].role, MessageRole::User)); @@ -894,6 +1190,26 @@ mod tests { )); } + #[test] + fn cancelled_turn_with_pending_message_suppresses_interrupted_hint() { + let mut app = App::test_default(); + app.status = AppStatus::Thinking; + app.turn.cancel_requested = true; + app.transcript.messages.push(user_message("hello")); + app.transcript.messages.push(empty_assistant_message()); + assert!( + app.pending_user_messages + .try_push_sending(pending_message("queued", "continue with this", Vec::new(),)) + .is_ok() + ); + + handle_turn_error_event(&mut app, "cancelled", None, None, None, None); + + assert_eq!(app.transcript.messages.len(), 1); + assert!(matches!(app.transcript.messages[0].role, MessageRole::User)); + assert_eq!(app.pending_user_messages.len(), 1); + } + #[test] fn turn_error_removes_empty_tail_assistant_before_error_message() { let mut app = App::test_default(); @@ -901,7 +1217,7 @@ mod tests { app.transcript.messages.push(user_message("hello")); app.transcript.messages.push(empty_assistant_message()); - handle_turn_error_event(&mut app, "boom", None, None, None); + handle_turn_error_event(&mut app, "boom", None, None, None, None); assert_eq!(app.transcript.messages.len(), 2); assert!(matches!(app.transcript.messages[0].role, MessageRole::User)); @@ -920,7 +1236,7 @@ mod tests { )); app.bind_active_turn_assistant(1); - handle_turn_complete_event(&mut app, None); + handle_turn_complete_event(&mut app, None, None); assert_eq!(app.status, AppStatus::Ready); assert_eq!(app.active_turn_assistant_idx(), None); @@ -946,7 +1262,7 @@ mod tests { app.bind_active_turn_assistant(1); app.chat_render.mark_resize_purge_replay_during_turn(); - handle_turn_complete_event(&mut app, None); + handle_turn_complete_event(&mut app, None, None); assert_eq!( app.surface_dirty.chat.rebuild, diff --git a/src/app/input_submit.rs b/src/app/input_submit.rs index 2ac49f2e..aa52dd32 100644 --- a/src/app/input_submit.rs +++ b/src/app/input_submit.rs @@ -23,7 +23,15 @@ pub(super) fn submit_input(app: &mut App) { app.session_runtime.prompt_suggestion = None; let submission = slash::ResolvedSubmission::resolve(text); - if app.is_agent_turn_active() && submission.class().requires_idle_turn() { + let has_active_or_queued_turn = matches!(app.status, AppStatus::Thinking | AppStatus::Running) + || !app.pending_user_messages.is_empty(); + if has_active_or_queued_turn && submission.is_prompt() { + dispatch_active_turn_prompt(app, submission.into_text()); + return; + } + if (app.is_agent_turn_active() || !app.pending_user_messages.is_empty()) + && submission.class().requires_idle_turn() + { let label = submission.blocked_label(); crate::app::events::push_active_turn_submission_blocked_notice(app, &label); tracing::debug!( @@ -87,6 +95,7 @@ fn dispatch_prompt_turn(app: &mut App, text: String) { }; let input_chars = text.chars().count(); let session_id = sid.to_string(); + let message_uuid = uuid::Uuid::new_v4().to_string(); // Take pending images for this turn. let images = std::mem::take(&mut app.pending_images); @@ -102,7 +111,7 @@ fn dispatch_prompt_turn(app: &mut App, text: String) { // The text already contains [Image #N] badges from the textarea, // so the model can correlate user references with image attachments. - match conn.prompt_with_images(sid.to_string(), text, images) { + match conn.prompt_with_images(sid.to_string(), message_uuid.clone(), text, images) { Ok(resp) => { crate::app::session_runtime::request_context_usage_refresh(app); tracing::info!( @@ -111,6 +120,7 @@ fn dispatch_prompt_turn(app: &mut App, text: String) { message = "prompt dispatched to the bridge", outcome = "success", session_id = %session_id, + message_uuid = %message_uuid, input_chars, stop_reason = ?resp.stop_reason, ); @@ -121,6 +131,68 @@ fn dispatch_prompt_turn(app: &mut App, text: String) { } } +fn dispatch_active_turn_prompt(app: &mut App, text: String) { + let Some(conn) = app.session_runtime.conn.clone() else { + return; + }; + let Some(session_id) = app.session_runtime.session_id.clone() else { + return; + }; + let message_uuid = uuid::Uuid::new_v4().to_string(); + let images = app.pending_images.clone(); + let pending = + super::PendingUserMessage::sending(message_uuid.clone(), text.clone(), images.clone()); + match app.pending_user_messages.try_push_sending(pending) { + Ok(()) => {} + Err(super::PendingUserMessageInsertError::AtCapacity(_)) => return, + Err(super::PendingUserMessageInsertError::DuplicateUuid(_)) => { + tracing::warn!( + target: crate::logging::targets::APP_INPUT, + event_name = "active_turn_prompt_duplicate_uuid", + message = "newly generated active-turn prompt UUID already exists", + outcome = "ignored", + session_id = %session_id, + message_uuid = %message_uuid, + ); + return; + } + } + + match conn.prompt_with_images(session_id.to_string(), message_uuid.clone(), text, images) { + Ok(_) => { + app.input.clear(); + app.pending_images.clear(); + app.request_active_surface_repaint(); + tracing::info!( + target: crate::logging::targets::APP_INPUT, + event_name = "active_turn_prompt_dispatched", + message = "active-turn prompt dispatched to the bridge", + outcome = "sending", + session_id = %session_id, + message_uuid = %message_uuid, + pending_message_count = app.pending_user_messages.len(), + ); + } + Err(error) => { + let _ = app.pending_user_messages.remove(&message_uuid); + crate::app::events::push_submission_feedback( + app, + super::SystemSeverity::Error, + &format!("Queued message could not be sent: {error}"), + ); + tracing::warn!( + target: crate::logging::targets::APP_INPUT, + event_name = "active_turn_prompt_dispatch_failed", + message = "active-turn prompt could not enter the bridge command queue", + outcome = "failure", + session_id = %session_id, + message_uuid = %message_uuid, + error = %error, + ); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -148,36 +220,78 @@ mod tests { } #[test] - fn submit_input_while_running_preserves_prompt_and_does_not_cancel() { + fn submit_input_while_running_queues_full_payload_without_touching_active_turn() { let (mut app, mut rx) = app_with_connection(); app.status = AppStatus::Running; app.transcript.messages.push(ChatMessage::new(MessageRole::Assistant, Vec::new(), None)); app.bind_active_turn_assistant(0); - app.input.set_text("next prompt"); + app.input.set_text("next prompt [Image #1]"); app.pending_images.push(crate::app::clipboard_image::ImageAttachment { - data: "image-data".to_owned(), + data: "aGVsbG8=".to_owned(), mime_type: "image/png".to_owned(), }); - let before = app.input.snapshot(); submit_input(&mut app); - assert_eq!(app.input.snapshot(), before); - assert_eq!(app.pending_images.len(), 1); - assert_eq!(app.pending_images[0].data, "image-data"); - assert_eq!(app.pending_images[0].mime_type, "image/png"); + assert!(app.input.text().is_empty()); + assert!(app.pending_images.is_empty()); assert!(!app.turn.cancel_requested); assert!(matches!(app.status, AppStatus::Running)); - assert!(rx.try_recv().is_err(), "rejected prompt must not dispatch or cancel"); - let [MessageBlock::Notice(notice)] = app.transcript.messages[0].blocks.as_slice() else { - panic!("expected inline active-turn notice"); + assert_eq!( + app.transcript.messages.len(), + 1, + "pending input must not enter the transcript before its correlated start" + ); + assert_eq!(app.pending_user_messages.len(), 1); + let pending = app.pending_user_messages.iter().next().expect("one pending user message"); + assert_eq!(pending.text, "next prompt [Image #1]"); + assert_eq!(pending.images.len(), 1); + assert_eq!(pending.images[0].data, "aGVsbG8="); + let envelope = rx.try_recv().expect("active-turn prompt should be sent"); + let BridgeCommand::Prompt { session_id, message_uuid, chunks } = envelope.command else { + panic!("expected prompt command"); }; - assert!(notice.text.text.contains("New prompts")); - assert_eq!(notice.severity, super::super::SystemSeverity::Info); + assert_eq!(session_id, "session-1"); + assert_eq!(message_uuid, pending.uuid); + assert_eq!(chunks.len(), 2); + assert!(rx.try_recv().is_err(), "active-turn prompt must not cancel the current turn"); } #[test] - fn repeated_active_turn_rejections_update_one_inline_notice() { + fn active_turn_prompt_at_capacity_preserves_composer_without_dispatch_or_notice() { + let (mut app, mut rx) = app_with_connection(); + app.status = AppStatus::Running; + for index in 0..crate::app::state::PendingUserMessages::CAPACITY { + app.pending_user_messages + .try_push_sending(super::super::PendingUserMessage::sending( + format!("queued-{index}"), + format!("queued message {index}"), + Vec::new(), + )) + .expect("queue slot should be available"); + } + app.input.set_text("keep this draft [Image #1]"); + app.pending_images.push(crate::app::clipboard_image::ImageAttachment { + data: "aGVsbG8=".to_owned(), + mime_type: "image/png".to_owned(), + }); + let input_before = app.input.snapshot(); + let images_before = app.pending_images.clone(); + + submit_input(&mut app); + + assert_eq!(app.input.snapshot(), input_before); + assert_eq!(app.pending_images, images_before); + assert_eq!( + app.pending_user_messages.len(), + crate::app::state::PendingUserMessages::CAPACITY + ); + assert!(app.transcript.messages.is_empty()); + assert!(rx.try_recv().is_err(), "capacity rejection must not reach the bridge"); + } + + #[test] + fn active_turn_prompt_does_not_change_slash_command_blocking() { let (mut app, mut rx) = app_with_connection(); app.status = AppStatus::Running; app.transcript.messages.push(ChatMessage::new(MessageRole::Assistant, Vec::new(), None)); @@ -195,7 +309,41 @@ mod tests { }; assert!(notice.text.text.contains("`/resume`")); assert_eq!(app.turn.notice_refs.len(), 1); - assert!(rx.try_recv().is_err()); + assert!(matches!( + rx.try_recv().expect("plain prompt should be queued").command, + BridgeCommand::Prompt { .. } + )); + assert!(rx.try_recv().is_err(), "blocked slash command must not be dispatched"); + } + + #[test] + fn active_turn_prompt_send_failure_preserves_exact_draft_and_images() { + let (mut app, rx) = app_with_connection(); + drop(rx); + app.status = AppStatus::Running; + app.input.set_text("retry [Image #1]"); + app.pending_images.push(crate::app::clipboard_image::ImageAttachment { + data: "aGVsbG8=".to_owned(), + mime_type: "image/png".to_owned(), + }); + let before = app.input.snapshot(); + let images_before = app.pending_images.clone(); + + submit_input(&mut app); + + assert_eq!(app.input.snapshot(), before); + assert_eq!(app.pending_images, images_before); + assert!(app.pending_user_messages.is_empty()); + assert!(matches!(app.status, AppStatus::Running)); + let message = app.transcript.messages.last().expect("send failure message"); + assert!(matches!( + message.role, + MessageRole::System(Some(super::super::SystemSeverity::Error)) + )); + let Some(MessageBlock::Text(text)) = message.blocks.last() else { + panic!("expected send failure text"); + }; + assert!(text.text.contains("could not be sent")); } #[test] @@ -354,7 +502,7 @@ mod tests { assert!(matches!(app.transcript.messages[1].role, MessageRole::Assistant)); let envelope = rx.try_recv().expect("advertised slash command should be sent"); match envelope.command { - BridgeCommand::Prompt { session_id, chunks } => { + BridgeCommand::Prompt { session_id, chunks, .. } => { assert_eq!(session_id, "session-1"); assert_eq!(chunks.len(), 1); assert_eq!(chunks[0].kind, "text"); @@ -422,7 +570,7 @@ mod tests { } #[test] - fn rejected_prompt_is_not_submitted_when_the_active_turn_completes() { + fn queued_prompt_is_not_redispatched_when_the_active_turn_completes() { let (mut app, mut rx) = app_with_connection(); app.status = AppStatus::Running; app.input.set_text("submit manually later"); @@ -430,14 +578,62 @@ mod tests { submit_input(&mut app); crate::app::events::handle_client_event( &mut app, - ClientEvent::TurnComplete { session_id: "session-1".to_owned(), terminal_reason: None }, + ClientEvent::TurnComplete { + session_id: "session-1".to_owned(), + queued_turn_count: None, + terminal_reason: None, + }, ); - assert_eq!(app.input.text(), "submit manually later"); + assert!(app.input.text().is_empty()); + assert_eq!(app.pending_user_messages.len(), 1); assert!(matches!(app.status, AppStatus::Ready)); - while let Ok(envelope) = rx.try_recv() { - assert!(!matches!(envelope.command, BridgeCommand::Prompt { .. })); - } + let commands = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert_eq!( + commands + .iter() + .filter(|envelope| matches!(envelope.command, BridgeCommand::Prompt { .. })) + .count(), + 1, + "turn completion must not redispatch queued input" + ); + } + + #[test] + fn prompt_submitted_between_queued_turns_joins_pending_projection() { + let (mut app, mut rx) = app_with_connection(); + app.status = AppStatus::Running; + app.input.set_text("second turn"); + submit_input(&mut app); + crate::app::events::handle_client_event( + &mut app, + ClientEvent::TurnComplete { + session_id: "session-1".to_owned(), + queued_turn_count: Some(1), + terminal_reason: None, + }, + ); + app.input.set_text("third turn"); + + submit_input(&mut app); + + assert!(app.input.text().is_empty()); + assert_eq!( + app.pending_user_messages + .iter() + .map(|message| message.text.as_str()) + .collect::>(), + ["second turn", "third turn"] + ); + assert!(matches!(app.status, AppStatus::Ready)); + let commands = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert_eq!( + commands + .iter() + .filter(|envelope| matches!(envelope.command, BridgeCommand::Prompt { .. })) + .count(), + 2 + ); } #[test] diff --git a/src/app/mod.rs b/src/app/mod.rs index 00de9c5e..b8ab5581 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -82,6 +82,7 @@ pub use state::{ UsageSourceKind, UsageSourceMode, UsageState, UsageWindow, UserDialogBlock, WelcomeBlock, hash_text_block_content, hash_welcome_block_content, is_execute_tool_name, }; +pub(crate) use state::{PendingUserMessage, PendingUserMessageInsertError}; pub use trust::TrustSelection; pub use update_check::start_update_check; pub(crate) use update_prompt::actions_for as update_prompt_actions; diff --git a/src/app/slash/mod.rs b/src/app/slash/mod.rs index 7dd95da0..56d437a2 100644 --- a/src/app/slash/mod.rs +++ b/src/app/slash/mod.rs @@ -110,6 +110,10 @@ impl ResolvedSubmission { } } + pub(crate) const fn is_prompt(&self) -> bool { + matches!(self, Self::Prompt { .. }) + } + pub(crate) fn blocked_label(&self) -> String { match self { Self::Prompt { .. } => "New prompts".to_owned(), diff --git a/src/app/state/app.rs b/src/app/state/app.rs index 1dfe1274..21f655a1 100644 --- a/src/app/state/app.rs +++ b/src/app/state/app.rs @@ -79,6 +79,8 @@ pub struct App { /// consumed on submit. No cap on count — this is a developer tool, so /// users are trusted to attach as many images as they need. pub(crate) pending_images: Vec, + /// Session-scoped projection of user messages accepted locally while an agent turn is active. + pub pending_user_messages: PendingUserMessages, /// Git repo context used by footer/status rendering and live branch tracking. pub(crate) git_context: GitContextState, /// Update prompt state for the startup fullscreen surface. @@ -256,6 +258,7 @@ impl App { pending_submit: None, paste: PasteState::default(), pending_images: Vec::new(), + pending_user_messages: PendingUserMessages::default(), git_context: GitContextState::default(), update_prompt: None, post_exit_action: None, diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs index e68125fe..79be95a0 100644 --- a/src/app/state/mod.rs +++ b/src/app/state/mod.rs @@ -15,6 +15,7 @@ mod autocomplete; mod focus_runtime; mod git_runtime; mod paste; +mod pending_messages; mod repaint; mod sdk_inventory; mod session_identity; @@ -42,6 +43,9 @@ pub use messages::{ hash_text_block_content, hash_welcome_block_content, }; pub use paste::PasteState; +pub(crate) use pending_messages::{ + PendingUserMessage, PendingUserMessageInsertError, PendingUserMessages, +}; pub use repaint::LayoutInvalidation as InvalidationLevel; pub use repaint::LayoutInvalidation; pub use sdk_inventory::SdkInventoryState; @@ -70,6 +74,7 @@ mod prelude { pub(super) use super::chat_render::ChatRenderState; pub(super) use super::messages::{ChatMessage, MessageBlock, MessageRole, NoticeDedupKey}; pub(super) use super::paste::PasteState; + pub(super) use super::pending_messages::PendingUserMessages; pub(super) use super::repaint::LayoutInvalidation as InvalidationLevel; pub(super) use super::sdk_inventory::SdkInventoryState; pub(super) use super::session_runtime::SessionRuntimeState; diff --git a/src/app/state/pending_messages.rs b/src/app/state/pending_messages.rs new file mode 100644 index 00000000..fd0aa896 --- /dev/null +++ b/src/app/state/pending_messages.rs @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Simon Peter Rothgang + +use crate::app::clipboard_image::ImageAttachment; +use std::collections::VecDeque; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingUserMessageState { + Sending, + Queued, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingUserMessage { + pub uuid: String, + pub text: String, + pub images: Vec, + pub state: PendingUserMessageState, +} + +impl PendingUserMessage { + #[must_use] + pub fn sending(uuid: String, text: String, images: Vec) -> Self { + Self { uuid, text, images, state: PendingUserMessageState::Sending } + } + + #[must_use] + pub fn first_line(&self) -> &str { + self.text.lines().next().unwrap_or_default() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingUserMessageInsertError { + AtCapacity(PendingUserMessage), + DuplicateUuid(PendingUserMessage), +} + +#[derive(Debug, Default)] +pub struct PendingUserMessages { + items: VecDeque, +} + +impl PendingUserMessages { + pub const CAPACITY: usize = 10; + + #[must_use] + pub fn len(&self) -> usize { + self.items.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn iter(&self) -> impl DoubleEndedIterator { + self.items.iter() + } + + pub fn try_push_sending( + &mut self, + message: PendingUserMessage, + ) -> Result<(), PendingUserMessageInsertError> { + if self.items.len() >= Self::CAPACITY { + return Err(PendingUserMessageInsertError::AtCapacity(message)); + } + if self.items.iter().any(|pending| pending.uuid == message.uuid) { + return Err(PendingUserMessageInsertError::DuplicateUuid(message)); + } + self.items.push_back(message); + Ok(()) + } + + pub fn mark_queued(&mut self, uuid: &str) -> bool { + let Some(message) = self.items.iter_mut().find(|pending| pending.uuid == uuid) else { + return false; + }; + if message.state == PendingUserMessageState::Queued { + return false; + } + message.state = PendingUserMessageState::Queued; + true + } + + pub fn take_started_prefix(&mut self, representative_uuid: &str) -> Vec { + let Some(last_idx) = self.items.iter().position(|item| item.uuid == representative_uuid) + else { + return Vec::new(); + }; + self.items.drain(..=last_idx).collect() + } + + pub fn remove(&mut self, uuid: &str) -> Option { + let idx = self.items.iter().position(|message| message.uuid == uuid)?; + self.items.remove(idx) + } + + pub fn drain(&mut self) -> Vec { + self.items.drain(..).collect() + } + + pub fn reconcile_interrupt_survivors(&mut self, still_queued: &[String]) -> usize { + let mut matched = 0; + for uuid in still_queued { + if let Some(message) = self.items.iter_mut().find(|pending| pending.uuid == *uuid) { + message.state = PendingUserMessageState::Queued; + matched += 1; + } + } + matched + } + + pub fn clear(&mut self) { + self.items.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn message(uuid: &str) -> PendingUserMessage { + PendingUserMessage::sending(uuid.to_owned(), format!("message {uuid}"), Vec::new()) + } + + #[test] + fn started_representative_drains_the_observed_coalesced_prefix() { + let mut pending = PendingUserMessages::default(); + assert!( + pending + .try_push_sending(PendingUserMessage::sending( + "one".to_owned(), + "first line\ncontinued".to_owned(), + Vec::new(), + )) + .is_ok() + ); + assert!(pending.try_push_sending(message("two")).is_ok()); + assert!(pending.try_push_sending(message("three")).is_ok()); + + let started = pending.take_started_prefix("two"); + + assert_eq!( + started.iter().map(|item| item.uuid.as_str()).collect::>(), + ["one", "two"] + ); + assert_eq!(started[0].text, "first line\ncontinued"); + assert_eq!(pending.iter().map(|item| item.uuid.as_str()).collect::>(), ["three"]); + } + + #[test] + fn transitions_are_uuid_keyed_and_idempotent() { + let mut pending = PendingUserMessages::default(); + assert!(pending.try_push_sending(message("one")).is_ok()); + assert!(matches!( + pending.try_push_sending(message("one")), + Err(PendingUserMessageInsertError::DuplicateUuid(message)) if message.uuid == "one" + )); + assert!(pending.mark_queued("one")); + assert!(!pending.mark_queued("one")); + assert!(!pending.mark_queued("unknown")); + assert!(pending.take_started_prefix("unknown").is_empty()); + assert_eq!(pending.len(), 1); + } + + #[test] + fn capacity_rejection_returns_message_and_removing_an_item_reopens_a_slot() { + let mut pending = PendingUserMessages::default(); + for index in 0..PendingUserMessages::CAPACITY { + assert!(pending.try_push_sending(message(&index.to_string())).is_ok()); + } + + let overflow = match pending.try_push_sending(message("overflow")) { + Err(PendingUserMessageInsertError::AtCapacity(message)) => message, + other => panic!("expected capacity rejection, got {other:?}"), + }; + assert_eq!(pending.len(), PendingUserMessages::CAPACITY); + assert_eq!(overflow.uuid, "overflow"); + + assert!(pending.remove("0").is_some()); + assert!(pending.try_push_sending(overflow).is_ok()); + assert_eq!(pending.len(), PendingUserMessages::CAPACITY); + } +} diff --git a/src/ui/input.rs b/src/ui/input.rs index 4a151099..0096c561 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -34,6 +34,7 @@ const HIGHLIGHT_IMAGE_BADGE_PRIORITY: u8 = 10; const LOGIN_HINT_LINES: u16 = 2; const CANCEL_HINT_LINES: u16 = 1; const PROMPT_SUGGESTION_HINT_LINES: u16 = 1; +const MAX_PENDING_MESSAGE_PREVIEW_ROWS: usize = 3; #[derive(Clone, Copy)] pub(crate) struct InputRenderGeometry { @@ -66,7 +67,13 @@ pub(crate) fn hint_line_count(app: &App) -> u16 { let cancel = if has_cancel_hint(app) { CANCEL_HINT_LINES } else { 0 }; let autocomplete = autocomplete::composer_hint_height(app); let suggestion = if has_prompt_suggestion_hint(app) { PROMPT_SUGGESTION_HINT_LINES } else { 0 }; - login + cancel + autocomplete + suggestion + let pending_messages = if app.pending_user_messages.is_empty() { + 0 + } else { + 1 + u16::try_from(app.pending_user_messages.len().min(MAX_PENDING_MESSAGE_PREVIEW_ROWS)) + .unwrap_or(u16::MAX) + }; + login + cancel + pending_messages + autocomplete + suggestion } pub(crate) fn compute_render_geometry(area: Rect, hint_lines: u16) -> InputRenderGeometry { @@ -290,6 +297,24 @@ mod tests { assert_eq!(visual_line_count(&mut app, 80), PROMPT_SUGGESTION_HINT_LINES + 1); } + #[test] + fn visual_line_count_includes_bounded_pending_message_rows() { + let mut app = App::test_default(); + for index in 0..5 { + assert!( + app.pending_user_messages + .try_push_sending(crate::app::PendingUserMessage::sending( + format!("message-{index}"), + format!("preview {index}"), + Vec::new(), + )) + .is_ok() + ); + } + + assert_eq!(visual_line_count(&mut app, 80), 5); + } + #[test] fn visual_line_count_includes_autocomplete_hint_rows() { let mut app = App::test_default(); diff --git a/src/ui/input_rows.rs b/src/ui/input_rows.rs index 36c9a22c..5236a706 100644 --- a/src/ui/input_rows.rs +++ b/src/ui/input_rows.rs @@ -10,6 +10,7 @@ const SPINNER_FRAMES: &[char] = &[ '\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}', '\u{2807}', '\u{280F}', ]; +const MAX_PENDING_MESSAGE_PREVIEW_ROWS: usize = 3; pub(crate) fn build_composer_hint_rows(app: &App) -> Vec> { let mut rows = Vec::new(); @@ -33,6 +34,27 @@ pub(crate) fn build_composer_hint_rows(app: &App) -> Vec> { ])); } + if !app.pending_user_messages.is_empty() { + let count = app.pending_user_messages.len(); + rows.push(Line::from(Span::styled( + format!("Queued Messages ({count}) · Esc interrupt & continue"), + Style::default().fg(theme::DIM), + ))); + let hidden = count.saturating_sub(MAX_PENDING_MESSAGE_PREVIEW_ROWS); + for (index, pending) in app + .pending_user_messages + .iter() + .skip(hidden) + .take(MAX_PENDING_MESSAGE_PREVIEW_ROWS) + .enumerate() + { + rows.push(Line::from(Span::styled( + format!(" {}. {}", hidden + index + 1, pending.first_line()), + Style::default().fg(theme::DIM), + ))); + } + } + if autocomplete::is_active(app) { rows.extend(autocomplete::composer_hint_rows(app)); } else if app.input.is_empty() @@ -84,7 +106,9 @@ pub(crate) fn blocked_input_lines(app: &App, reason: ComposerBlockReason) -> Vec #[cfg(test)] mod tests { use super::{blocked_input_lines, build_composer_hint_rows}; - use crate::app::{App, AppStatus, ComposerBlockReason, FocusTarget, LoginHint}; + use crate::app::{ + App, AppStatus, ComposerBlockReason, FocusTarget, LoginHint, PendingUserMessage, + }; fn line_text(line: &ratatui::text::Line<'_>) -> String { line.spans.iter().map(|span| span.content.as_ref()).collect() @@ -115,6 +139,59 @@ mod tests { assert!(line_text(&rows[1]).contains("Suggestion: Write tests")); } + #[test] + fn pending_message_rows_show_dimmed_summary_and_preview() { + let mut app = App::test_default(); + assert!( + app.pending_user_messages + .try_push_sending(PendingUserMessage::sending( + "one".to_owned(), + "first line\nsecond line".to_owned(), + Vec::new(), + )) + .is_ok() + ); + + let rows = build_composer_hint_rows(&app); + + assert_eq!(rows.len(), 2); + assert_eq!(line_text(&rows[0]), "Queued Messages (1) · Esc interrupt & continue"); + assert_eq!(line_text(&rows[1]), " 1. first line"); + assert!( + rows.iter() + .flat_map(|row| row.spans.iter()) + .all(|span| span.style.fg == Some(crate::ui::theme::DIM)) + ); + } + + #[test] + fn pending_message_rows_show_only_latest_three_previews_with_stable_numbers() { + let mut app = App::test_default(); + for index in 1..=5 { + assert!( + app.pending_user_messages + .try_push_sending(PendingUserMessage::sending( + format!("message-{index}"), + format!("preview {index}"), + Vec::new(), + )) + .is_ok() + ); + } + + let rows = build_composer_hint_rows(&app); + + assert_eq!( + rows.iter().map(line_text).collect::>(), + [ + "Queued Messages (5) · Esc interrupt & continue", + " 3. preview 3", + " 4. preview 4", + " 5. preview 5", + ] + ); + } + #[test] fn build_composer_hint_rows_omits_compaction_status() { let mut app = App::test_default(); diff --git a/tests/integration/helpers.rs b/tests/integration/helpers.rs index 509d70d8..d4a5bbb1 100644 --- a/tests/integration/helpers.rs +++ b/tests/integration/helpers.rs @@ -22,7 +22,11 @@ pub fn session_update(update: model::SessionUpdate) -> ClientEvent { } pub fn turn_complete() -> ClientEvent { - ClientEvent::TurnComplete { session_id: "test-session".to_owned(), terminal_reason: None } + ClientEvent::TurnComplete { + session_id: "test-session".to_owned(), + queued_turn_count: None, + terminal_reason: None, + } } pub fn permission_request( diff --git a/tests/integration/state_transitions.rs b/tests/integration/state_transitions.rs index 0e11ad15..14867e91 100644 --- a/tests/integration/state_transitions.rs +++ b/tests/integration/state_transitions.rs @@ -132,6 +132,7 @@ async fn error_then_new_turn_recovers() { ClientEvent::TurnError { session_id: "test-session".to_owned(), message: "timeout".into(), + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, @@ -378,6 +379,7 @@ async fn error_during_tool_calls_leaves_tool_calls_intact() { ClientEvent::TurnError { session_id: "test-session".to_owned(), message: "crashed".into(), + queued_turn_count: None, api_error_status: None, terminal_reason: None, }, From e325bec8fcdc184d024571f1811e8a4a95a7d971 Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Wed, 2 Sep 2026 20:44:31 +0200 Subject: [PATCH 3/4] feat(agent-sdk): complete 0.3.258 host integration - Add MCP resource links across the bridge, Rust state, and TUI. - Preserve ambient task metadata and background Bash completion. - Harden Write diffs and extend structured tool rendering. - Add SDK compatibility and regression coverage. --- agent-sdk/src/bridge.contract.test.ts | 27 +- agent-sdk/src/bridge.test.ts | 524 +++++++++++++++++++- agent-sdk/src/bridge/mcp.ts | 1 + agent-sdk/src/bridge/message_handlers.ts | 19 +- agent-sdk/src/bridge/resource_links.test.ts | 85 ++++ agent-sdk/src/bridge/resource_links.ts | 123 +++++ agent-sdk/src/bridge/tasks.ts | 3 + agent-sdk/src/bridge/tool_calls.ts | 29 +- agent-sdk/src/bridge/tooling.test.ts | 146 ++++++ agent-sdk/src/bridge/tooling.ts | 176 +++++-- agent-sdk/src/types.ts | 11 + src/agent/model/mcp.rs | 56 +++ src/agent/model/tasks.rs | 7 + src/agent/model/tools.rs | 3 +- src/agent/types.rs | 10 + src/app/connect/type_converters.rs | 67 ++- src/app/events/tool_updates.rs | 3 + src/app/state/history_retention.rs | 13 + src/app/state/tests.rs | 22 + src/app/tasks.rs | 20 + src/ui/tool_call/artifact.rs | 233 ++++++++- src/ui/tool_call/repl.rs | 33 +- src/ui/tool_call/schedule_wakeup.rs | 46 +- src/ui/tool_call/standard.rs | 53 ++ src/ui/tool_call/tests.rs | 36 ++ 25 files changed, 1688 insertions(+), 58 deletions(-) create mode 100644 agent-sdk/src/bridge/resource_links.test.ts create mode 100644 agent-sdk/src/bridge/resource_links.ts diff --git a/agent-sdk/src/bridge.contract.test.ts b/agent-sdk/src/bridge.contract.test.ts index f3cc2991..2250d3aa 100644 --- a/agent-sdk/src/bridge.contract.test.ts +++ b/agent-sdk/src/bridge.contract.test.ts @@ -1,8 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import readline from "node:readline"; import { fileURLToPath } from "node:url"; @@ -10,6 +11,18 @@ type BridgeEnvelope = Record; const bridgePath = join(dirname(fileURLToPath(import.meta.url)), "bridge.js"); +function productionTypeScriptFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return productionTypeScriptFiles(path); + } + return entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") + ? [path] + : []; + }); +} + class SpawnedBridge { readonly child: ChildProcessWithoutNullStreams; readonly stderrLines: string[] = []; @@ -277,3 +290,15 @@ test("bridge process reports actionable session initialization failure", async ( await bridge.stop(); } }); + +test("production bridge source stays on the public main SDK export", () => { + const sourceDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "../src"); + const forbiddenDeepImport = /@anthropic-ai\/claude-agent-sdk\/(?:browser|bridge)/; + const forbiddenFactory = /\bcreateSdkMcpServer\b/; + + for (const path of productionTypeScriptFiles(sourceDirectory)) { + const source = readFileSync(path, "utf8"); + assert.doesNotMatch(source, forbiddenDeepImport, path); + assert.doesNotMatch(source, forbiddenFactory, path); + } +}); diff --git a/agent-sdk/src/bridge.test.ts b/agent-sdk/src/bridge.test.ts index a010ec40..e9e5b051 100644 --- a/agent-sdk/src/bridge.test.ts +++ b/agent-sdk/src/bridge.test.ts @@ -59,7 +59,9 @@ import { emitMcpSnapshotEvent, emitMcpSnapshotFromStatuses, handleMcpAuthenticateCommand, + handleMcpReconnectCommand, handleMcpSetServersCommand, + handleMcpToggleCommand, startMcpAuthSnapshotMonitor, } from "./bridge/mcp.js"; import { @@ -100,6 +102,7 @@ import { requestAskUserQuestionAnswers } from "./bridge/user_interaction.js"; import { flushPendingWorkerShutdown, handleResultMessage, + handleUserToolResultBlocks, sdkMessageDiagnosticFields, } from "./bridge/message_handlers.js"; import { dispatchCancelTurnCommand } from "./bridge/command_dispatch.js"; @@ -1083,6 +1086,62 @@ test("handleMcpSetServersCommand emits SDK result", async () => { ]); }); +test("MCP failed-add status remains authoritative when the SDK result reports the server as added", async () => { + const session = makeSessionState(); + session.query = { + setMcpServers: async () => ({ + added: ["throws-on-connect"], + removed: [], + errors: {}, + }), + mcpServerStatus: async () => [ + { + name: "throws-on-connect", + scope: "dynamic", + status: "failed", + error: "connection threw during initialization", + tools: [], + }, + ], + } as unknown as import("@anthropic-ai/claude-agent-sdk").Query; + + const events = await captureBridgeEventsAsync(async () => { + await handleMcpSetServersCommand( + session, + { + command: "mcp_set_servers", + session_id: "session-1", + servers: { + "throws-on-connect": { + type: "stdio", + command: "missing-mcp-server", + }, + }, + }, + "req-mcp-failed-add", + ); + }); + + const result = events.find((event) => event.event === "mcp_set_servers_result"); + assert.deepEqual(result && "result" in result ? result.result : undefined, { + added: ["throws-on-connect"], + removed: [], + errors: {}, + }); + const snapshot = events.find((event) => event.event === "mcp_snapshot") as + | { servers?: Array<{ name: string; status: string; error?: string }> } + | undefined; + assert.deepEqual(snapshot?.servers, [ + { + name: "throws-on-connect", + scope: "dynamic", + status: "failed", + error: "connection threw during initialization", + tools: [], + }, + ]); +}); + test("handleMcpAuthenticateCommand emits a structured error when the runtime method is absent", async () => { const session = makeSessionState(); @@ -1146,12 +1205,31 @@ test("handleMcpAuthenticateCommand emits a structured error for an incompatible ]); }); -test("handleMcpSetServersCommand emits MCP operation error on failure", async () => { +test("failed MCP server updates publish the authoritative follow-up snapshot without conflating names", async () => { const session = makeSessionState(); + const controls: Array<[string, string, boolean?]> = []; session.query = { setMcpServers: async () => { throw new Error("dynamic update failed"); }, + mcpServerStatus: async () => [ + { name: "docs", scope: "local", status: "connected", tools: [] }, + { name: "docs", scope: "project", status: "disabled", tools: [] }, + { name: "foo", scope: "dynamic", status: "connected", tools: [] }, + { + name: "foo__bar", + scope: "dynamic", + status: "failed", + error: "connection threw", + tools: [], + }, + ], + reconnectMcpServer: async (name: string) => { + controls.push(["reconnect", name]); + }, + toggleMcpServer: async (name: string, enabled: boolean) => { + controls.push(["toggle", name, enabled]); + }, } as unknown as import("@anthropic-ai/claude-agent-sdk").Query; const events = await captureBridgeEventsAsync(async () => { @@ -1164,9 +1242,28 @@ test("handleMcpSetServersCommand emits MCP operation error on failure", async () }, "req-mcp-set", ); + await handleMcpReconnectCommand( + session, + { + command: "mcp_reconnect", + session_id: "session-1", + server_name: "foo__bar", + }, + "req-mcp-reconnect", + ); + await handleMcpToggleCommand( + session, + { + command: "mcp_toggle", + session_id: "session-1", + server_name: "foo", + enabled: false, + }, + "req-mcp-toggle", + ); }); - assert.deepEqual(events, [ + assert.deepEqual(events.slice(0, 2), [ { request_id: "req-mcp-set", event: "mcp_operation_error", @@ -1183,6 +1280,36 @@ test("handleMcpSetServersCommand emits MCP operation error on failure", async () message: "failed to set MCP servers: dynamic update failed", }, ]); + const snapshot = events.find((event) => event.event === "mcp_snapshot"); + assert.equal(snapshot?.source, "mcp_set_servers"); + const snapshotServers = ( + snapshot as + | { + servers?: Array<{ + name: string; + scope?: string; + status: string; + }>; + } + | undefined + )?.servers; + assert.deepEqual( + (snapshotServers ?? []).map((server) => [ + server.name, + server.scope, + server.status, + ]), + [ + ["docs", "local", "connected"], + ["docs", "project", "disabled"], + ["foo", "dynamic", "connected"], + ["foo__bar", "dynamic", "failed"], + ], + ); + assert.deepEqual(controls, [ + ["reconnect", "foo__bar"], + ["toggle", "foo", false], + ]); }); test("bridgeMcpConfigToSdk maps latest MCP fields to SDK casing", () => { @@ -1576,6 +1703,50 @@ test("normalizeStructuredUsage keeps stable fields and tolerates malformed optio }); }); +test("managed model inventory and pricing remain SDK-owned aggregate flows", () => { + const session = makeSessionState(); + session.availableModels = mapAvailableModels([ + { + value: "managed-sonnet", + resolvedModel: "claude-sonnet-managed", + displayName: "Organization Sonnet", + description: "Selected by managed modelPicker settings", + supportsEffort: true, + }, + ]); + + const connected = buildConnectBridgeEvent(session, "connected"); + assert.equal(connected.event, "connected"); + assert.deepEqual(connected.available_models, [ + { + id: "managed-sonnet", + resolved_model: "claude-sonnet-managed", + display_name: "Organization Sonnet", + description: "Selected by managed modelPicker settings", + supports_effort: true, + supported_effort_levels: [], + }, + ]); + + assert.deepEqual( + normalizeStructuredUsage({ + session: { + total_cost_usd: 1.75, + model_usage: { + "claude-sonnet-managed": { + inputTokens: 10, + outputTokens: 5, + costUSD: 1.75, + thinkingTokens: 2, + costBasis: "managed-model-pricing", + }, + }, + }, + }).session, + { total_cost_usd: 1.75, model_count: 1 }, + ); +}); + test("normalizeStructuredUsage isolates an incompatible root as an empty snapshot", () => { assert.deepEqual(normalizeStructuredUsage(null), {}); assert.deepEqual(normalizeStructuredUsage("not-an-object"), {}); @@ -2941,7 +3112,7 @@ test("emitToolResultUpdate finalizes deferred Agent completion and unlinks lifec assert.equal(session.taskIdsByToolUseId.has("tool-1"), false); }); -test("handleTaskSystemMessage ignores lifecycle content for concrete output tools", () => { +test("task notifications close Bash without replacing concrete tool output", () => { const session = makeSessionState(); const protectedTools = [ createToolCall("tool-bash", "Bash", { command: "git status" }), @@ -2975,10 +3146,21 @@ test("handleTaskSystemMessage ignores lifecycle content for concrete output tool } }); - assert.deepEqual(events, []); + assert.equal( + events.filter( + (event) => + event.event === "session_update" && + (event.update as { type?: unknown } | undefined)?.type === + "tool_call_update", + ).length, + 1, + ); for (const toolCall of protectedTools) { const stored = session.toolCalls.get(toolCall.tool_call_id); - assert.equal(stored?.status, "in_progress"); + assert.equal( + stored?.status, + toolCall.tool_call_id === "tool-bash" ? "completed" : "in_progress", + ); assert.equal( stored?.raw_output, `actual output for ${toolCall.tool_call_id}`, @@ -3487,6 +3669,180 @@ test("handleSdkMessage emits main-thread user-message start before streamed cont assert.equal(events[1]?.event, "session_update"); }); +test("direct user MCP result appends resource links without replacing text", () => { + const session = makeSessionState(); + const toolCall = createToolCall("tool-mcp", "mcp__docs__export", {}); + toolCall.status = "in_progress"; + session.toolCalls.set(toolCall.tool_call_id, toolCall); + + const events = captureBridgeEvents(() => { + assert.equal( + handleUserToolResultBlocks(session, { + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool-mcp", + content: "Export complete", + }, + ], + }, + toolUseResult: { + resourceLinks: [ + { + uri: "mcp://docs/report.csv", + name: "report.csv", + mimeType: "text/csv", + size: 42, + }, + ], + _meta: { + resourceLinks: [ + { uri: "mcp://private", name: "must-not-leak" }, + ], + }, + }, + }), + true, + ); + }); + + const update = events.at(-1)?.update as { + tool_call_update?: { fields?: { content?: unknown[] } }; + }; + assert.deepEqual(update.tool_call_update?.fields?.content, [ + { + type: "content", + content: { type: "text", text: "Export complete" }, + }, + { + type: "resource_link", + uri: "mcp://docs/report.csv", + name: "report.csv", + mime_type: "text/csv", + size: 42, + }, + ]); +}); + +test("task notification appends resource links with or without a summary", () => { + for (const summary of ["Export complete", undefined]) { + const session = makeSessionState(); + const events = captureBridgeEvents(() => { + handleTaskSystemMessage(session, "task_started", { + task_id: "task-mcp", + tool_use_id: "tool-mcp", + description: "Export report", + }); + handleTaskSystemMessage(session, "task_notification", { + task_id: "task-mcp", + tool_use_id: "tool-mcp", + status: "completed", + ...(summary ? { summary } : {}), + resource_links: [ + { uri: "mcp://docs/report.csv", name: "report.csv" }, + { uri: "", name: "malformed" }, + ], + }); + }); + + const update = events.at(-1)?.update as { + tool_call_update?: { fields?: { content?: unknown[] } }; + }; + assert.deepEqual(update.tool_call_update?.fields?.content, [ + ...(summary + ? [ + { + type: "content", + content: { type: "text", text: summary }, + }, + ] + : []), + { + type: "resource_link", + uri: "mcp://docs/report.csv", + name: "report.csv", + }, + ]); + } +}); + +test("repeated background MCP resource-link delivery replaces content idempotently", () => { + const session = makeSessionState(); + captureBridgeEvents(() => { + emitToolCall(session, "tool-mcp-repeat", "mcp__docs__export", {}); + for (let attempt = 0; attempt < 2; attempt += 1) { + handleTaskSystemMessage(session, "task_notification", { + task_id: "task-mcp-repeat", + tool_use_id: "tool-mcp-repeat", + status: "completed", + summary: "Export complete", + resource_links: [ + { uri: "mcp://docs/report.csv", name: "report.csv" }, + ], + }); + } + }); + + assert.deepEqual(session.toolCalls.get("tool-mcp-repeat")?.content, [ + { + type: "content", + content: { type: "text", text: "Export complete" }, + }, + { + type: "resource_link", + uri: "mcp://docs/report.csv", + name: "report.csv", + }, + ]); + assert.equal(session.toolCalls.get("tool-mcp-repeat")?.status, "completed"); +}); + +test("background Bash remains open across turn interruption until its final task notification", () => { + const session = makeSessionState(); + + captureBridgeEvents(() => { + emitToolCall(session, "tool-bash-background", "Bash", { + command: "npm run watch", + }); + emitToolResultUpdate(session, "tool-bash-background", false, { + stdout: "watching", + stderr: "", + interrupted: false, + backgroundTaskId: "bash-task-1", + backgroundedByUser: true, + }); + handleResultMessage(session, { + type: "result", + subtype: "error_during_execution", + errors: ["Interrupted by user"], + }); + }); + + assert.equal( + session.toolCalls.get("tool-bash-background")?.status, + "in_progress", + ); + assert.equal( + session.taskToolUseIds.get("bash-task-1"), + "tool-bash-background", + ); + + captureBridgeEvents(() => { + handleTaskSystemMessage(session, "task_notification", { + task_id: "bash-task-1", + status: "stopped", + summary: "Background command stopped", + }); + }); + + const toolCall = session.toolCalls.get("tool-bash-background"); + assert.equal(toolCall?.status, "killed"); + assert.match(toolCall?.raw_output ?? "", /watching/); + assert.equal(session.taskToolUseIds.has("bash-task-1"), false); + assert.equal(session.taskIdsByToolUseId.has("tool-bash-background"), false); +}); + test("handleSdkMessage maps command lifecycle start to the submitted message UUID", () => { const session = makeSessionState(); const events = captureBridgeEvents(() => { @@ -5076,6 +5432,70 @@ test("handleSdkMessage emits MCP snapshot from init status payload", () => { }); }); +test("the next per-turn init reports the live mode after a mode switch", async () => { + sessions.clear(); + const session = makeSessionState(); + const permissionModeCalls: string[] = []; + session.mode = "default"; + session.query = { + setPermissionMode: async (mode: string) => { + permissionModeCalls.push(mode); + }, + supportedCommands: async () => [], + supportedAgents: async () => [], + } as unknown as import("@anthropic-ai/claude-agent-sdk").Query; + sessions.set(session.sessionId, session); + + try { + const events = await captureBridgeEventsAsync(async () => { + await handleSessionControlCommand( + { + command: "set_mode", + session_id: session.sessionId, + mode: "plan", + }, + "req-mode-switch", + promptControlDeps(), + ); + handleSdkMessage(session, { + type: "system", + subtype: "init", + session_id: session.sessionId, + model: "haiku", + permissionMode: "plan", + } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + }); + + assert.deepEqual(permissionModeCalls, ["plan"]); + assert.equal(session.mode, "plan"); + assert.deepEqual( + events + .filter((event) => event.event === "session_update") + .flatMap((event) => { + const update = event.update as + | { + type?: string; + current_mode_id?: string; + mode?: { current_mode_id?: string }; + } + | undefined; + if (update?.type === "current_mode_update") { + return update.current_mode_id ? [update.current_mode_id] : []; + } + if (update?.type === "mode_state_update") { + return update.mode?.current_mode_id + ? [update.mode.current_mode_id] + : []; + } + return []; + }), + ["plan", "plan"], + ); + } finally { + sessions.clear(); + } +}); + test("authority snapshots publish fast mode set during initialization", () => { const session = makeSessionState(); assert.equal( @@ -7116,6 +7536,100 @@ test("handleTaskSystemMessage preserves task correlation metadata", () => { ); }); +test("ambient task lifecycle stays in inventory without creating a visible tool call", () => { + const session = makeSessionState(); + const events = captureBridgeEvents(() => { + handleTaskSystemMessage(session, "task_started", { + task_id: "ambient-watch", + description: "Watch artifact updates", + task_type: "artifact_watch", + ambient: true, + }); + }); + + assert.equal(session.toolCalls.size, 0); + assert.deepEqual( + events.map((event) => event.update), + [ + { + type: "task_state_update", + source: "task_lifecycle", + tasks: [ + { + task_id: "ambient-watch", + subject: "Watch artifact updates", + description: "Watch artifact updates", + status: "in_progress", + blocks: [], + blocked_by: [], + metadata: { task_type: "artifact_watch", ambient: true }, + }, + ], + removed_task_ids: [], + is_complete_snapshot: false, + }, + ], + ); +}); + +test("background task replacement preserves ambient inventory metadata", () => { + const session = makeSessionState(); + const events = captureBridgeEvents(() => { + handleSdkMessage(session, { + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { + task_id: "ambient-watch", + task_type: "artifact_watch", + description: "Watch artifact updates", + ambient: true, + }, + { + task_id: "user-task", + task_type: "agent", + description: "Run checks", + ambient: false, + }, + ], + uuid: "12345678-1234-1234-1234-123456789abc", + session_id: "session-1", + } as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + }); + + const update = events.at(-1)?.update as { + tasks?: Array<{ task_id: string; metadata?: Record }>; + }; + assert.deepEqual(update.tasks, [ + { + task_id: "ambient-watch", + subject: "Watch artifact updates", + description: "Watch artifact updates", + status: "in_progress", + blocks: [], + blocked_by: [], + metadata: { + sdk_background_task: true, + task_type: "artifact_watch", + ambient: true, + }, + }, + { + task_id: "user-task", + subject: "Run checks", + description: "Run checks", + status: "in_progress", + blocks: [], + blocked_by: [], + metadata: { + sdk_background_task: true, + task_type: "agent", + ambient: false, + }, + }, + ]); +}); + test("parseCommandEnvelope validates set_effort command", () => { for (const effort of ["low", "medium", "high", "xhigh", "max"] as const) { const parsed = parseCommandEnvelope( diff --git a/agent-sdk/src/bridge/mcp.ts b/agent-sdk/src/bridge/mcp.ts index b5b78fb7..c83c320f 100644 --- a/agent-sdk/src/bridge/mcp.ts +++ b/agent-sdk/src/bridge/mcp.ts @@ -540,6 +540,7 @@ export async function handleMcpSetServersCommand( `failed to set MCP servers: ${message}`, requestId, ); + await handleMcpStatusCommand(session, requestId, "mcp_set_servers"); } } diff --git a/agent-sdk/src/bridge/message_handlers.ts b/agent-sdk/src/bridge/message_handlers.ts index c231b096..a15f15bd 100644 --- a/agent-sdk/src/bridge/message_handlers.ts +++ b/agent-sdk/src/bridge/message_handlers.ts @@ -38,6 +38,8 @@ import { resolveTaskToolUseId, defersTaskNotificationCompletion, toolAcceptsTaskLifecycle, + toolAcceptsTerminalTaskNotification, + toolPreservesTaskNotificationOutput, taskProgressText, taskUpdatedFields, type ToolCorrelationMetadata, @@ -83,6 +85,7 @@ import { } from "./session_lifecycle.js"; import { bridgeLogger, LOG_TARGETS } from "./logger.js"; import { emitMcpSnapshotFromStatuses } from "./mcp.js"; +import { appendResourceLinks } from "./resource_links.js"; export function textFromPrompt( command: Extract, @@ -195,6 +198,7 @@ function sdkTaskMetadata( ...(typeof msg.is_backgrounded === "boolean" ? { is_backgrounded: msg.is_backgrounded } : {}), + ...(typeof msg.ambient === "boolean" ? { ambient: msg.ambient } : {}), ...(spawnDepth !== undefined ? { spawn_depth: spawnDepth } : {}), }; return Object.keys(taskMetadata).length > 0 ? taskMetadata : undefined; @@ -724,7 +728,11 @@ export function handleTaskSystemMessage( } const toolCall = ensureToolCallVisible(session, toolUseId, "Agent", {}); - if (!toolAcceptsTaskLifecycle(toolCall)) { + const acceptsLifecycle = toolAcceptsTaskLifecycle(toolCall); + const acceptsTerminalNotification = + subtype === "task_notification" && + toolAcceptsTerminalTaskNotification(toolCall); + if (!acceptsLifecycle && !acceptsTerminalNotification) { if (taskId) { unlinkTaskToolUse(session, taskId); } @@ -822,12 +830,19 @@ export function handleTaskSystemMessage( if (messageTaskMetadata) { fields.task_metadata = messageTaskMetadata; } - if (summary) { + if (summary && !toolPreservesTaskNotificationOutput(toolCall)) { fields.raw_output = summary; fields.content = [ { type: "content", content: { type: "text", text: summary } }, ]; } + const contentWithResourceLinks = appendResourceLinks( + fields.content, + msg.resource_links, + ); + if (contentWithResourceLinks !== undefined) { + fields.content = contentWithResourceLinks; + } if (Object.keys(fields).length > 0) { emitToolCallUpdate(session, toolUseId, fields, "task_notification"); } diff --git a/agent-sdk/src/bridge/resource_links.test.ts b/agent-sdk/src/bridge/resource_links.test.ts new file mode 100644 index 00000000..8a49653e --- /dev/null +++ b/agent-sdk/src/bridge/resource_links.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resourceLinkContents } from "./resource_links.js"; + +test("resourceLinkContents validates optional fields and skips malformed entries", () => { + assert.deepEqual( + resourceLinkContents([ + { + uri: " mcp://docs/report ", + name: " report.csv ", + title: " Quarterly report ", + description: " Generated report ", + mimeType: " text/csv ", + size: 42, + annotations: { audience: ["user"], priority: 0.8 }, + }, + { uri: "", name: "missing-uri" }, + { uri: "mcp://docs/missing-name", name: "" }, + { + uri: "mcp://docs/bad-annotations", + name: "bad", + annotations: { invalid: undefined }, + }, + { uri: "mcp://docs/readme", name: "README.md", size: -1 }, + ]), + [ + { + type: "resource_link", + uri: "mcp://docs/report", + name: "report.csv", + title: "Quarterly report", + description: "Generated report", + mime_type: "text/csv", + size: 42, + annotations: { audience: ["user"], priority: 0.8 }, + }, + { + type: "resource_link", + uri: "mcp://docs/readme", + name: "README.md", + }, + ], + ); +}); + +test("resourceLinkContents enforces count and serialized-size caps per entry", () => { + const links = Array.from({ length: 55 }, (_, index) => ({ + uri: `mcp://docs/${index}`, + name: `file-${index}`, + })); + links.splice(1, 0, { + uri: "mcp://docs/oversized", + name: "x".repeat(70 * 1024), + }); + + const parsed = resourceLinkContents(links); + const parsedLinks = parsed.filter( + (link): link is Extract => + link.type === "resource_link", + ); + + assert.equal(parsedLinks.length, 50); + assert.equal( + parsedLinks.some((link) => link.name.length > 64 * 1024), + false, + ); + assert.equal(parsedLinks.at(0)?.uri, "mcp://docs/0"); + assert.equal(parsedLinks.at(1)?.uri, "mcp://docs/1"); +}); + +test("resourceLinkContents preserves annotation keys as inert JSON data", () => { + const annotations = JSON.parse('{"__proto__":{"polluted":true}}') as unknown; + const [content] = resourceLinkContents([ + { uri: "mcp://docs/safe", name: "safe", annotations }, + ]); + + assert.equal(content?.type, "resource_link"); + if (content?.type !== "resource_link") { + return; + } + assert.ok(content.annotations); + assert.deepEqual(content.annotations, annotations); + assert.equal(Object.hasOwn(content.annotations, "__proto__"), true); + assert.equal(({} as { polluted?: boolean }).polluted, undefined); +}); diff --git a/agent-sdk/src/bridge/resource_links.ts b/agent-sdk/src/bridge/resource_links.ts new file mode 100644 index 00000000..c4bd3de4 --- /dev/null +++ b/agent-sdk/src/bridge/resource_links.ts @@ -0,0 +1,123 @@ +import type { Json, ToolCallContent } from "../types.js"; +import { asRecordOrNull } from "./shared.js"; + +const MAX_RESOURCE_LINKS = 50; +const MAX_RESOURCE_LINK_BYTES = 64 * 1024; + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function jsonValue(value: unknown): Json | undefined { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return value; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + if (Array.isArray(value)) { + const entries: Json[] = []; + for (const entry of value) { + const parsed = jsonValue(entry); + if (parsed === undefined) { + return undefined; + } + entries.push(parsed); + } + return entries; + } + const record = asRecordOrNull(value); + if (!record) { + return undefined; + } + const parsed: Array<[string, Json]> = []; + for (const [key, entry] of Object.entries(record)) { + const value = jsonValue(entry); + if (value === undefined) { + return undefined; + } + parsed.push([key, value]); + } + return Object.fromEntries(parsed); +} + +function resourceLinkContent(value: unknown): ToolCallContent | undefined { + const link = asRecordOrNull(value); + const uri = nonEmptyString(link?.uri); + const name = nonEmptyString(link?.name); + if (!link || !uri || !name) { + return undefined; + } + + const title = nonEmptyString(link.title); + const description = nonEmptyString(link.description); + const mimeType = nonEmptyString(link.mimeType); + const size = + typeof link.size === "number" && + Number.isSafeInteger(link.size) && + link.size >= 0 + ? link.size + : undefined; + const annotationsValue = + link.annotations === undefined ? undefined : jsonValue(link.annotations); + const annotations = asRecordOrNull(annotationsValue) as + | Record + | null; + if (link.annotations !== undefined && !annotations) { + return undefined; + } + + return { + type: "resource_link", + uri, + name, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + ...(mimeType ? { mime_type: mimeType } : {}), + ...(size !== undefined ? { size } : {}), + ...(annotations ? { annotations } : {}), + }; +} + +export function resourceLinkContents(value: unknown): ToolCallContent[] { + if (!Array.isArray(value)) { + return []; + } + + const links: ToolCallContent[] = []; + let serializedBytes = 2; + for (const entry of value) { + if (links.length >= MAX_RESOURCE_LINKS) { + break; + } + const link = resourceLinkContent(entry); + if (!link) { + continue; + } + const linkBytes = Buffer.byteLength(JSON.stringify(link)); + const separatorBytes = links.length > 0 ? 1 : 0; + if ( + serializedBytes + separatorBytes + linkBytes > + MAX_RESOURCE_LINK_BYTES + ) { + continue; + } + serializedBytes += separatorBytes + linkBytes; + links.push(link); + } + return links; +} + +export function appendResourceLinks( + content: ToolCallContent[] | undefined, + value: unknown, +): ToolCallContent[] | undefined { + const links = resourceLinkContents(value); + return links.length > 0 ? [...(content ?? []), ...links] : content; +} diff --git a/agent-sdk/src/bridge/tasks.ts b/agent-sdk/src/bridge/tasks.ts index 814b0fa1..bab24819 100644 --- a/agent-sdk/src/bridge/tasks.ts +++ b/agent-sdk/src/bridge/tasks.ts @@ -1069,6 +1069,7 @@ function lifecycleMetadata( "total_paused_ms", "blocked", "parent_agent_id", + "ambient", ]) { copyValue(msg, key); copyValue(patch, key); @@ -1156,6 +1157,7 @@ export function applyBackgroundTasksChanged( } const taskType = nonEmptyString(task.task_type); const description = nonEmptyString(task.description); + const ambient = typeof task.ambient === "boolean" ? task.ambient : undefined; return { task_id: taskId, subject: description ?? taskType ?? taskId, @@ -1166,6 +1168,7 @@ export function applyBackgroundTasksChanged( metadata: { [BACKGROUND_TASK_METADATA_KEY]: true, ...(taskType ? { task_type: taskType } : {}), + ...(ambient !== undefined ? { ambient } : {}), }, }; }) diff --git a/agent-sdk/src/bridge/tool_calls.ts b/agent-sdk/src/bridge/tool_calls.ts index b7a873e9..9779bcfd 100644 --- a/agent-sdk/src/bridge/tool_calls.ts +++ b/agent-sdk/src/bridge/tool_calls.ts @@ -15,6 +15,7 @@ import { buildToolResultFields, createToolCall, } from "./tooling.js"; +import { appendResourceLinks } from "./resource_links.js"; type ToolUpdateKind = | "initial" @@ -106,6 +107,24 @@ export function toolAcceptsTaskLifecycle(base: ToolCall | undefined): boolean { return Boolean(baseToolName && TASK_LIFECYCLE_TOOL_NAMES.has(baseToolName)); } +export function toolAcceptsTerminalTaskNotification( + base: ToolCall | undefined, +): boolean { + const baseToolName = toolName(base); + return Boolean( + baseToolName && + (TASK_LIFECYCLE_TOOL_NAMES.has(baseToolName) || + baseToolName === "Bash" || + baseToolName.startsWith("mcp__")), + ); +} + +export function toolPreservesTaskNotificationOutput( + base: ToolCall | undefined, +): boolean { + return toolName(base) === "Bash"; +} + export function defersTaskNotificationCompletion( base: ToolCall | undefined, ): boolean { @@ -530,6 +549,14 @@ export function emitToolResultUpdate( asRecordOrNull(base?.raw_input) ?? {}, ), ); + const resultRecord = asRecordOrNull(rawResult); + const contentWithResourceLinks = appendResourceLinks( + fields.content, + resultRecord?.resourceLinks, + ); + if (contentWithResourceLinks !== undefined) { + fields.content = contentWithResourceLinks; + } applyToolNonExecutionMetadata(fields, nonExecutionMetadata); if (!isError && !nonExecutionMetadata) { const taskId = backgroundToolLaunchTaskIdFromResult( @@ -566,7 +593,7 @@ export function finalizeOpenToolCalls( continue; } if ( - toolAcceptsTaskLifecycle(toolCall) && + toolAcceptsTerminalTaskNotification(toolCall) && activeTaskIdForToolUse(session, toolUseId) ) { continue; diff --git a/agent-sdk/src/bridge/tooling.test.ts b/agent-sdk/src/bridge/tooling.test.ts index 76444772..ed0538c1 100644 --- a/agent-sdk/src/bridge/tooling.test.ts +++ b/agent-sdk/src/bridge/tooling.test.ts @@ -676,6 +676,116 @@ test("buildToolResultFields maps structured Write output to diff content", () => ]); }); +test("buildToolResultFields distinguishes new, unchanged, and unavailable Write diffs", () => { + const base = createToolCall("tc-write-cases", "Write", { + file_path: "src/main.ts", + content: "new", + }); + const created = buildToolResultFields( + false, + { + type: "create", + filePath: "src/new.ts", + content: "created", + originalFile: null, + structuredPatch: [], + }, + base, + ); + assert.deepEqual(created.content, [ + { + type: "diff", + old_path: "src/new.ts", + new_path: "src/new.ts", + old: "", + new: "created", + }, + ]); + + const unchanged = buildToolResultFields( + false, + { + type: "update", + filePath: "src/main.ts", + content: "same", + originalFile: "same", + structuredPatch: [], + }, + base, + ); + assert.equal(unchanged.raw_output, "No changes to src/main.ts."); + assert.equal(unchanged.content?.at(0)?.type, "content"); + + for (const gitPatch of [undefined, ""]) { + const unavailable = buildToolResultFields( + false, + { + type: "update", + filePath: "src/huge.ts", + content: "replacement", + originalFile: null, + structuredPatch: [], + ...(gitPatch === undefined + ? {} + : { gitDiff: { patch: gitPatch, status: "modified" } }), + }, + base, + ); + assert.equal( + unavailable.raw_output, + "Updated src/huge.ts; the previous content was unavailable, so a diff could not be displayed.", + ); + assert.equal( + unavailable.content?.some((content) => content.type === "diff"), + false, + ); + } + + const ambiguous = buildToolResultFields( + false, + { + filePath: "src/ambiguous.ts", + content: "replacement", + originalFile: null, + structuredPatch: [], + }, + base, + ); + assert.equal( + ambiguous.raw_output, + "Updated src/ambiguous.ts; the previous content was unavailable, so a diff could not be displayed.", + ); + assert.equal(ambiguous.content?.some((content) => content.type === "diff"), false); +}); + +test("buildToolResultFields replays structured Write output from transcript JSON", () => { + const base = createToolCall("tc-write-replay", "Write", { + file_path: "src/replayed.ts", + content: "new", + }); + const fields = buildToolResultFields( + false, + JSON.stringify({ + type: "update", + filePath: "src/replayed.ts", + content: "new", + originalFile: "old", + structuredPatch: [], + }), + base, + ); + + assert.deepEqual(fields.content, [ + { + type: "diff", + old_path: "src/replayed.ts", + new_path: "src/replayed.ts", + old: "old", + new: "new", + }, + ]); +}); + test("buildToolResultFields preserves Edit diff content from input and structured repository", () => { const base = createToolCall("tc-e", "Edit", { file_path: "src/main.ts", @@ -2184,6 +2294,42 @@ test("buildToolResultFields parses REPL transcript JSON", () => { assert.equal(fields.raw_output?.includes("hidden-document"), false); }); +test("buildToolResultFields renders bounded REPL omission and page-failure details", () => { + const base = createToolCall("tc-repl-omissions", "REPL", { + code: "await readPages()", + }); + const fields = buildToolResultFields( + false, + { + code: "await readPages()", + stdout: "", + stderr: "", + imagesOmitted: 3, + imagePagesFailed: Array.from({ length: 22 }, (_, index) => ({ + page: index + 1, + file: "manual.pdf", + error: `render failed ${index + 1}`, + })), + imagePagesFailedOmitted: 2, + documentsOmitted: 1, + }, + base, + ); + + assert.equal(fields.status, "completed"); + assert.match(fields.raw_output ?? "", /^Images omitted: 3/m); + assert.match( + fields.raw_output ?? "", + /Failed image page: 1 \(file manual\.pdf: render failed 1\)/, + ); + assert.equal( + fields.raw_output?.match(/^Failed image page:/gm)?.length, + 20, + ); + assert.match(fields.raw_output ?? "", /Failed image pages omitted: 4/); + assert.match(fields.raw_output ?? "", /Documents omitted: 1/); +}); + test("buildToolResultFields renders Monitor launch output as structured text", () => { const base = createToolCall("tc-monitor", "Monitor", { description: "watch deploy logs", diff --git a/agent-sdk/src/bridge/tooling.ts b/agent-sdk/src/bridge/tooling.ts index 14a5d9d2..7e726f97 100644 --- a/agent-sdk/src/bridge/tooling.ts +++ b/agent-sdk/src/bridge/tooling.ts @@ -1023,53 +1023,99 @@ function editDiffFromInput(rawInput: Json | undefined): ToolCall["content"] { ]; } -function writeDiffFromResult(rawContent: unknown): ToolCall["content"] { - const candidates = Array.isArray(rawContent) ? rawContent : [rawContent]; +type WriteResultPresentation = + | { kind: "diff"; content: ToolCall["content"] } + | { kind: "text"; text: string }; + +function boundedDisplayText(value: string, maxCharacters = 240): string { + return value.length > maxCharacters + ? `${value.slice(0, maxCharacters - 3)}...` + : value; +} + +function writeResultPresentation( + rawContent: unknown, +): WriteResultPresentation | undefined { + const candidates = resultRecordCandidates(rawContent, undefined); + const parsed = parseJsonCandidate(rawContent); + if (parsed !== undefined) { + candidates.push(...resultRecordCandidates(parsed, undefined)); + } for (const candidate of candidates) { - const record = asRecordOrNull(candidate); - if (!record) { - continue; - } + const record = candidate; const filePath = typeof record.filePath === "string" ? record.filePath : typeof record.file_path === "string" ? record.file_path : ""; - const content = typeof record.content === "string" ? record.content : ""; + const content = + typeof record.content === "string" ? record.content : undefined; const originalRaw = "originalFile" in record ? record.originalFile : "original_file" in record ? record.original_file : undefined; - const gitDiff = asRecordOrNull(record.gitDiff); + const gitDiff = asRecordOrNull(record.gitDiff ?? record.git_diff); const repository = typeof gitDiff?.repository === "string" && gitDiff.repository.trim().length > 0 ? gitDiff.repository.trim() : undefined; - if (!filePath || !content || originalRaw === undefined) { + if (!filePath || originalRaw === undefined) { continue; } - const original = - typeof originalRaw === "string" - ? originalRaw - : originalRaw === null - ? "" - : ""; - return [ - { - type: "diff", - old_path: filePath, - new_path: filePath, - old: original, - new: content, - ...(repository ? { repository } : {}), - }, - ]; + if (originalRaw === null) { + if (record.type === "create" && content !== undefined) { + return { + kind: "diff", + content: [ + { + type: "diff", + old_path: filePath, + new_path: filePath, + old: "", + new: content, + ...(repository ? { repository } : {}), + }, + ], + }; + } + return { + kind: "text", + text: + record.type === "create" + ? `Created ${boundedDisplayText(filePath)}; the written content was unavailable, so a diff could not be displayed.` + : `Updated ${boundedDisplayText(filePath)}; the previous content was unavailable, so a diff could not be displayed.`, + }; + } + if (content === undefined) { + continue; + } + if (typeof originalRaw === "string") { + if (originalRaw === content) { + return { + kind: "text", + text: `No changes to ${boundedDisplayText(filePath)}.`, + }; + } + return { + kind: "diff", + content: [ + { + type: "diff", + old_path: filePath, + new_path: filePath, + old: originalRaw, + new: content, + ...(repository ? { repository } : {}), + }, + ], + }; + } } - return []; + return undefined; } function editDiffFromResult( @@ -2158,7 +2204,11 @@ function hasConcreteReplField(record: Record): boolean { "stderr" in record || "registeredTools" in record || "images" in record || - "documents" in record + "documents" in record || + "imagesOmitted" in record || + "imagePagesFailed" in record || + "imagePagesFailedOmitted" in record || + "documentsOmitted" in record ); } @@ -2234,6 +2284,50 @@ function replResultFields( if (Array.isArray(candidate.documents) && candidate.documents.length > 0) { lines.push(`Documents: ${candidate.documents.length}`); } + const imagesOmitted = nonNegativeInteger(candidate.imagesOmitted); + if (imagesOmitted !== undefined && imagesOmitted > 0) { + lines.push(`Images omitted: ${imagesOmitted}`); + } + const failedPages = Array.isArray(candidate.imagePagesFailed) + ? candidate.imagePagesFailed + : []; + const failedPageLimit = 20; + let renderedFailedPages = 0; + let locallyOmittedFailedPages = 0; + for (const value of failedPages) { + const failedPage = asRecordOrNull(value); + const page = nonNegativeInteger(failedPage?.page); + if (!failedPage || page === undefined) { + continue; + } + if (renderedFailedPages >= failedPageLimit) { + locallyOmittedFailedPages += 1; + continue; + } + const file = nonEmptyString(failedPage.file); + const error = nonEmptyString(failedPage.error); + const context = [ + file ? `file ${boundedDisplayText(file)}` : undefined, + error ? boundedDisplayText(error) : undefined, + ] + .filter((value): value is string => value !== undefined) + .join(": "); + lines.push( + `Failed image page: ${page}${context ? ` (${context})` : ""}`, + ); + renderedFailedPages += 1; + } + const upstreamFailedPagesOmitted = + nonNegativeInteger(candidate.imagePagesFailedOmitted) ?? 0; + const failedPagesOmitted = + upstreamFailedPagesOmitted + locallyOmittedFailedPages; + if (failedPagesOmitted > 0) { + lines.push(`Failed image pages omitted: ${failedPagesOmitted}`); + } + const documentsOmitted = nonNegativeInteger(candidate.documentsOmitted); + if (documentsOmitted !== undefined && documentsOmitted > 0) { + lines.push(`Documents omitted: ${documentsOmitted}`); + } return { output: lines.length > 0 ? lines.join("\n") : undefined, failed }; } @@ -2492,6 +2586,21 @@ function backgroundLaunchResultFields( rawResult: unknown, rawContent: unknown, ): BackgroundLaunchResult | undefined { + if (isShellToolName(toolName)) { + const record = findShellResultRecord(rawResult, rawContent); + const taskId = + typeof record?.backgroundTaskId === "string" + ? record.backgroundTaskId.trim() + : ""; + if (record && taskId) { + return { + output: buildShellDisplayOutput(record), + taskId, + failed: false, + keepRunning: true, + }; + } + } return ( monitorResultFields(toolName, rawResult, rawContent) ?? workflowResultFields(toolName, rawResult, rawContent) @@ -2880,9 +2989,16 @@ export function buildToolResultFields( } if (!isError && toolName === "Write") { - const structuredDiff = writeDiffFromResult(rawContent); - if (structuredDiff.length > 0) { - fields.content = structuredDiff; + const presentation = writeResultPresentation(rawContent); + if (presentation?.kind === "diff") { + fields.content = presentation.content; + return fields; + } + if (presentation?.kind === "text") { + fields.raw_output = presentation.text; + fields.content = [ + { type: "content", content: { type: "text", text: presentation.text } }, + ]; return fields; } const inputDiff = writeDiffFromInput(base?.raw_input); diff --git a/agent-sdk/src/types.ts b/agent-sdk/src/types.ts index be0d49d9..6f0320db 100644 --- a/agent-sdk/src/types.ts +++ b/agent-sdk/src/types.ts @@ -165,6 +165,16 @@ export type ToolCallContent = mime_type?: string; text?: string; blob_saved_to?: string; + } + | { + type: "resource_link"; + uri: string; + name: string; + title?: string; + description?: string; + mime_type?: string; + size?: number; + annotations?: Record; }; export interface BashOutputMetadata { @@ -235,6 +245,7 @@ export interface TaskMetadata { terminal_status?: string; blocked?: boolean; parent_agent_id?: string; + ambient?: boolean; subagent_retry?: SubagentRetryUpdate; } diff --git a/src/agent/model/mcp.rs b/src/agent/model/mcp.rs index 9b8c4c6b..49382107 100644 --- a/src/agent/model/mcp.rs +++ b/src/agent/model/mcp.rs @@ -12,6 +12,62 @@ pub struct McpResource { pub blob_saved_to: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpResourceLink { + pub uri: String, + pub name: String, + pub title: Option, + pub description: Option, + pub mime_type: Option, + pub size: Option, + pub annotations: Option>, +} + +impl McpResourceLink { + #[must_use] + pub fn new(uri: impl Into, name: impl Into) -> Self { + Self { + uri: uri.into(), + name: name.into(), + title: None, + description: None, + mime_type: None, + size: None, + annotations: None, + } + } + + #[must_use] + pub fn title(mut self, title: Option) -> Self { + self.title = title.filter(|value| !value.trim().is_empty()); + self + } + + #[must_use] + pub fn description(mut self, description: Option) -> Self { + self.description = description.filter(|value| !value.trim().is_empty()); + self + } + + #[must_use] + pub fn mime_type(mut self, mime_type: Option) -> Self { + self.mime_type = mime_type.filter(|value| !value.trim().is_empty()); + self + } + + #[must_use] + pub const fn size(mut self, size: Option) -> Self { + self.size = size; + self + } + + #[must_use] + pub fn annotations(mut self, annotations: Option>) -> Self { + self.annotations = annotations; + self + } +} + impl McpResource { #[must_use] pub fn new(uri: impl Into) -> Self { diff --git a/src/agent/model/tasks.rs b/src/agent/model/tasks.rs index 8bd3971f..add481cf 100644 --- a/src/agent/model/tasks.rs +++ b/src/agent/model/tasks.rs @@ -20,6 +20,7 @@ pub struct TaskMetadata { pub terminal_status: Option, pub blocked: Option, pub parent_agent_id: Option, + pub ambient: Option, pub subagent_retry: Option, } @@ -139,6 +140,12 @@ impl TaskMetadata { self } + #[must_use] + pub const fn ambient(mut self, ambient: Option) -> Self { + self.ambient = ambient; + self + } + #[must_use] pub fn subagent_retry(mut self, subagent_retry: Option) -> Self { self.subagent_retry = subagent_retry; diff --git a/src/agent/model/tools.rs b/src/agent/model/tools.rs index f736e5cc..2b22487e 100644 --- a/src/agent/model/tools.rs +++ b/src/agent/model/tools.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use super::content::{Content, ContentBlock, TextContent}; -use super::mcp::McpResource; +use super::mcp::{McpResource, McpResourceLink}; use super::tasks::TaskMetadata; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -95,6 +95,7 @@ pub enum ToolCallContent { Content(Content), Diff(Diff), McpResource(McpResource), + ResourceLink(McpResourceLink), Terminal(TerminalToolCallContent), } diff --git a/src/agent/types.rs b/src/agent/types.rs index 8721a31f..eb83da01 100644 --- a/src/agent/types.rs +++ b/src/agent/types.rs @@ -476,6 +476,7 @@ pub struct TaskMetadata { pub terminal_status: Option, pub blocked: Option, pub parent_agent_id: Option, + pub ambient: Option, pub subagent_retry: Option, } @@ -512,6 +513,15 @@ pub enum ToolCallContent { text: Option, blob_saved_to: Option, }, + ResourceLink { + uri: String, + name: String, + title: Option, + description: Option, + mime_type: Option, + size: Option, + annotations: Option>, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/app/connect/type_converters.rs b/src/app/connect/type_converters.rs index 3a74b729..e73c4252 100644 --- a/src/app/connect/type_converters.rs +++ b/src/app/connect/type_converters.rs @@ -953,6 +953,7 @@ fn convert_task_metadata(task_metadata: types::TaskMetadata) -> model::TaskMetad .terminal_status(task_metadata.terminal_status) .blocked(task_metadata.blocked) .parent_agent_id(task_metadata.parent_agent_id) + .ambient(task_metadata.ambient) .subagent_retry(task_metadata.subagent_retry.map(convert_subagent_retry_update)) } @@ -998,6 +999,22 @@ fn convert_tool_call_content( .blob_saved_to(blob_saved_to), )) } + types::ToolCallContent::ResourceLink { + uri, + name, + title, + description, + mime_type, + size, + annotations, + } => Some(model::ToolCallContent::ResourceLink( + model::McpResourceLink::new(uri, name) + .title(title) + .description(description) + .mime_type(mime_type) + .size(size) + .annotations(annotations), + )), } } @@ -1703,6 +1720,7 @@ mod tests { terminal_status: Some("completed".to_owned()), blocked: Some(true), parent_agent_id: Some("agent-parent".to_owned()), + ambient: Some(true), subagent_retry: None, }), ..types::ToolCallUpdateFields::default() @@ -1727,7 +1745,8 @@ mod tests { .summary(Some("Validation complete".to_owned())) .terminal_status(Some("completed".to_owned())) .blocked(Some(true)) - .parent_agent_id(Some("agent-parent".to_owned())), + .parent_agent_id(Some("agent-parent".to_owned())) + .ambient(Some(true)), ) ); } @@ -1761,6 +1780,7 @@ mod tests { terminal_status: Some("killed".to_owned()), blocked: Some(false), parent_agent_id: Some("agent-root".to_owned()), + ambient: Some(false), subagent_retry: None, }), locations: Vec::new(), @@ -1788,7 +1808,8 @@ mod tests { .summary(Some("Review stopped".to_owned())) .terminal_status(Some("killed".to_owned())) .blocked(Some(false)) - .parent_agent_id(Some("agent-root".to_owned())), + .parent_agent_id(Some("agent-root".to_owned())) + .ambient(Some(false)), ) ); } @@ -1865,6 +1886,48 @@ mod tests { ); } + #[test] + fn convert_tool_call_preserves_mcp_resource_link_metadata() { + let annotations = std::collections::BTreeMap::from([( + "audience".to_owned(), + serde_json::json!(["user"]), + )]); + let tool_call = convert_tool_call(types::ToolCall { + tool_call_id: "tool-link".to_owned(), + title: "Export report".to_owned(), + kind: "other".to_owned(), + status: "completed".to_owned(), + source_message_uuid: None, + content: vec![types::ToolCallContent::ResourceLink { + uri: "mcp://docs/report.csv".to_owned(), + name: "report.csv".to_owned(), + title: Some("Quarterly report".to_owned()), + description: Some("Generated report".to_owned()), + mime_type: Some("text/csv".to_owned()), + size: Some(42), + annotations: Some(annotations.clone()), + }], + raw_input: None, + raw_output: None, + output_metadata: None, + task_metadata: None, + locations: Vec::new(), + meta: None, + }); + + assert_eq!( + tool_call.content, + vec![model::ToolCallContent::ResourceLink( + model::McpResourceLink::new("mcp://docs/report.csv", "report.csv") + .title(Some("Quarterly report".to_owned())) + .description(Some("Generated report".to_owned())) + .mime_type(Some("text/csv".to_owned())) + .size(Some(42)) + .annotations(Some(annotations)), + )] + ); + } + #[test] fn map_mcp_server_status_converts_latest_config_fields() { let status = types::McpServerStatus { diff --git a/src/app/events/tool_updates.rs b/src/app/events/tool_updates.rs index e3e34048..3dedbfbe 100644 --- a/src/app/events/tool_updates.rs +++ b/src/app/events/tool_updates.rs @@ -269,6 +269,9 @@ fn apply_tool_call_task_metadata_update( if task_metadata.parent_agent_id.is_some() { merged.parent_agent_id.clone_from(&task_metadata.parent_agent_id); } + if task_metadata.ambient.is_some() { + merged.ambient = task_metadata.ambient; + } match &task_metadata.subagent_retry { Some(model::SubagentRetryUpdate::Waiting { .. }) => { merged.subagent_retry.clone_from(&task_metadata.subagent_retry); diff --git a/src/app/state/history_retention.rs b/src/app/state/history_retention.rs index fb15cbff..189724ef 100644 --- a/src/app/state/history_retention.rs +++ b/src/app/state/history_retention.rs @@ -92,6 +92,19 @@ impl super::App { .saturating_add( resource.blob_saved_to.as_ref().map_or(0, std::path::PathBuf::capacity), ), + model::ToolCallContent::ResourceLink(link) => link + .uri + .capacity() + .saturating_add(link.name.capacity()) + .saturating_add(link.title.as_ref().map_or(0, String::capacity)) + .saturating_add(link.description.as_ref().map_or(0, String::capacity)) + .saturating_add(link.mime_type.as_ref().map_or(0, String::capacity)) + .saturating_add( + link.annotations + .as_ref() + .and_then(|value| serde_json::to_string(value).ok()) + .map_or(0, |value| value.len()), + ), model::ToolCallContent::Terminal(term) => term.terminal_id.capacity(), } } diff --git a/src/app/state/tests.rs b/src/app/state/tests.rs index 479ce8ce..09a08114 100644 --- a/src/app/state/tests.rs +++ b/src/app/state/tests.rs @@ -489,6 +489,28 @@ fn assistant_tool_message_with_pending_permission(id: &str) -> ChatMessage { ) } +#[test] +fn history_retention_accounts_for_mcp_resource_link_allocations() { + let baseline = + assistant_tool_message("resource-link-baseline", model::ToolCallStatus::Completed); + let baseline_bytes = App::measure_message_bytes(&baseline); + let mut linked = + assistant_tool_message("resource-link-baseline", model::ToolCallStatus::Completed); + let MessageBlock::ToolCall(tool_call) = &mut linked.blocks[0] else { + panic!("expected tool call block"); + }; + tool_call.content = vec![model::ToolCallContent::ResourceLink( + model::McpResourceLink::new("mcp://docs/report.csv", "report.csv") + .title(Some("Quarterly report".to_owned())) + .annotations(Some(std::collections::BTreeMap::from([( + "audience".to_owned(), + serde_json::json!(["user"]), + )]))), + )]; + + assert!(App::measure_message_bytes(&linked) > baseline_bytes); +} + fn pending_user_dialog_message(request_id: &str) -> ChatMessage { let (tx, _rx) = tokio::sync::oneshot::channel(); ChatMessage::new( diff --git a/src/app/tasks.rs b/src/app/tasks.rs index 161ef416..a945114e 100644 --- a/src/app/tasks.rs +++ b/src/app/tasks.rs @@ -383,6 +383,26 @@ mod tests { assert!(!app.sdk_inventory.tasks.iter().any(|task| task.task_id == "bg-old")); } + #[test] + fn ambient_inventory_task_does_not_enter_active_subagent_tracking() { + let mut app = App::test_default(); + let mut ambient = background_task("ambient-watch", "watch artifact updates"); + ambient.metadata = Some(serde_json::json!({ + "sdk_background_task": true, + "ambient": true + })); + + apply_task_state_update( + &mut app, + model::TaskStateUpdate::new(model::TaskUpdateSource::BackgroundTasks) + .tasks(vec![ambient]) + .complete_snapshot(true), + ); + + assert_eq!(app.sdk_inventory.tasks.len(), 1); + assert!(app.turn.active_task_ids.is_empty()); + } + #[test] fn create_tool_title_uses_numbered_task_state() { let mut app = App::test_default(); diff --git a/src/ui/tool_call/artifact.rs b/src/ui/tool_call/artifact.rs index 72b931bd..4287af42 100644 --- a/src/ui/tool_call/artifact.rs +++ b/src/ui/tool_call/artifact.rs @@ -149,14 +149,7 @@ fn render_output_fields(object: &Map) -> Vec> { artifact_fields.push(ToolField::new("Capabilities", capabilities)); } if let Some(stored) = object.get("stored").and_then(Value::as_object) { - if let Some(contract) = typed::json_string(stored, "contract") { - artifact_fields.push(ToolField::new("Stored contract", contract)); - } - if let Some(capabilities) = - stored.get("capabilities").and_then(typed::non_empty_compact_json) - { - artifact_fields.push(ToolField::new("Stored capabilities", capabilities)); - } + artifact_fields.extend(render_stored_output_fields(stored)); } if let Some(warnings) = typed::json_string_array(object.get("warnings")) { artifact_fields.push(ToolField::new("Warnings", warnings.join("; "))); @@ -167,6 +160,9 @@ fn render_output_fields(object: &Map) -> Vec> { if let Some(updated) = typed::json_bool(object, "updated") { artifact_fields.push(ToolField::new("Updated", typed::bool_label(updated))); } + if let Some(audience) = typed::json_string(object, "audience") { + artifact_fields.push(ToolField::new("Audience", audience)); + } if let Some(live_subscription) = typed::json_string(object, "liveSubscription") { artifact_fields.push(ToolField::new("Live subscription", live_subscription)); } @@ -187,6 +183,7 @@ fn render_output_fields(object: &Map) -> Vec> { "warnings", "contract", "updated", + "audience", "liveSubscription", "read", "artifactRead", @@ -194,6 +191,11 @@ fn render_output_fields(object: &Map) -> Vec> { "asset_list", "asset_read", "asset_delete", + "watch", + "unwatch", + "watches", + "filter_url", + "arms", ], ) { artifact_fields.push(ToolField::new("Additional output", additional)); @@ -202,6 +204,32 @@ fn render_output_fields(object: &Map) -> Vec> { artifact_fields } +fn render_stored_output_fields(stored: &Map) -> Vec> { + let mut fields = Vec::new(); + if let Some(contract) = typed::json_string(stored, "contract") { + fields.push(ToolField::new("Stored contract", contract)); + } + if let Some(capabilities) = stored.get("capabilities").and_then(typed::non_empty_compact_json) { + fields.push(ToolField::new("Stored capabilities", capabilities)); + } + if let Some(preferred_contract) = typed::json_string(stored, "preferredContract") { + fields.push(ToolField::new("Preferred contract", preferred_contract)); + } + if let Some(carried) = typed::json_bool(stored, "carried") { + fields.push(ToolField::new("Stored state carried", typed::bool_label(carried))); + } + if let Some(read) = typed::json_string(stored, "read") { + fields.push(ToolField::new("Stored read state", read)); + } + if let Some(additional) = additional_json( + stored, + &["contract", "preferredContract", "capabilities", "carried", "read"], + ) { + fields.push(ToolField::new("Additional stored output", additional)); + } + fields +} + fn render_output_object(object: &Map) -> Vec> { let mut lines = fields::render_fields(render_output_fields(object)); if let Some(read) = object.get("read") { @@ -221,6 +249,15 @@ fn render_output_object(object: &Map) -> Vec> { if let Some(asset_delete) = object.get("asset_delete") { lines.extend(render_asset_delete_output(asset_delete)); } + if let Some(watch) = object.get("watch") { + lines.extend(render_watch_output(watch)); + } + if let Some(unwatch) = object.get("unwatch") { + lines.extend(render_unwatch_output(unwatch)); + } + if object.get("watches").is_some() || object.get("arms").is_some() { + lines.extend(render_watch_status_output(object)); + } if let Some(artifacts) = object.get("artifacts").and_then(Value::as_array) { for (index, artifact) in artifacts.iter().enumerate() { let Some(artifact) = artifact.as_object() else { @@ -235,12 +272,154 @@ fn render_output_object(object: &Map) -> Vec> { let title = typed::json_string(artifact, "title").unwrap_or(""); let url = typed::json_string(artifact, "url").unwrap_or(""); let relation = typed::json_string(artifact, "rel").map(|rel| format!("({rel}) ")); + let favicon = typed::json_string(artifact, "favicon") + .map(|value| format!(" — icon {value}")) + .unwrap_or_default(); let updated = typed::json_string(artifact, "updatedAt") .map(|timestamp| format!(" — updated {timestamp}")) .unwrap_or_default(); + let additional = + additional_json(artifact, &["title", "url", "favicon", "updatedAt", "rel"]) + .map(|value| format!(" — additional {value}")) + .unwrap_or_default(); lines.push(fields::render_dynamic_field( format!("Artifact {}", index + 1), - format!("{}{title} — {url}{updated}", relation.unwrap_or_default()), + format!( + "{}{title} — {url}{favicon}{updated}{additional}", + relation.unwrap_or_default() + ), + )); + } + } + lines +} + +fn render_watch_output(value: &Value) -> Vec> { + let Some(watch) = value.as_object() else { + return compact_value_field("Watch", value); + }; + let mut values = Vec::new(); + for (key, label) in [ + ("url", "Watch URL"), + ("outcome", "Watch outcome"), + ("reason", "Watch reason"), + ("durable_skip_reason", "Durable skip reason"), + ("task_id", "Watch task ID"), + ("rail", "Watch rail"), + ("trigger_id", "Watch trigger ID"), + ("durable_since", "Durable since"), + ("detail", "Watch detail"), + ("note", "Watch note"), + ] { + if let Some(value) = typed::json_string(watch, key) { + values.push(ToolField::new(label, value)); + } + } + if let Some(watching) = typed::json_bool(watch, "watching") { + values.push(ToolField::new("Watching", typed::bool_label(watching))); + } + for (key, label) in [ + ("since", "Watch since"), + ("token_expires_at", "Token expires at"), + ("status", "Watch status"), + ] { + if let Some(value) = typed::json_i64(watch, key) { + values.push(ToolField::new(label, value.to_string())); + } + } + if let Some(events) = typed::json_string_array(watch.get("events")) { + values.push(ToolField::new("Watch events", events.join(", "))); + } + if let Some(additional) = additional_json( + watch, + &[ + "url", + "watching", + "outcome", + "reason", + "durable_skip_reason", + "task_id", + "since", + "token_expires_at", + "rail", + "trigger_id", + "durable_since", + "status", + "detail", + "note", + "events", + ], + ) { + values.push(ToolField::new("Additional watch output", additional)); + } + fields::render_fields(values) +} + +fn render_unwatch_output(value: &Value) -> Vec> { + let Some(unwatch) = value.as_object() else { + return compact_value_field("Unwatch", value); + }; + let mut values = Vec::new(); + if let Some(url) = typed::json_string(unwatch, "url") { + values.push(ToolField::new("Unwatch URL", url)); + } + if let Some(was_watching) = typed::json_bool(unwatch, "was_watching") { + values.push(ToolField::new("Was watching", typed::bool_label(was_watching))); + } + if let Some(additional) = additional_json(unwatch, &["url", "was_watching"]) { + values.push(ToolField::new("Additional unwatch output", additional)); + } + fields::render_fields(values) +} + +fn render_watch_status_output(object: &Map) -> Vec> { + let mut lines = Vec::new(); + if let Some(filter_url) = typed::json_string(object, "filter_url") { + lines.push(fields::render_field("Watch filter", filter_url)); + } + if let Some(watches) = object.get("watches").and_then(Value::as_array) { + lines.push(fields::render_field("Watches", watches.len().to_string())); + for (index, watch) in watches.iter().enumerate() { + lines.extend(compact_value_field(format!("Watcher {}", index + 1), watch)); + } + } + if let Some(arms) = object.get("arms").and_then(Value::as_array) { + lines.push(fields::render_field("Watch arms", arms.len().to_string())); + for (index, arm) in arms.iter().enumerate() { + let Some(arm) = arm.as_object() else { + lines.extend(compact_value_field(format!("Watch arm {}", index + 1), arm)); + continue; + }; + let url = typed::json_string(arm, "url").unwrap_or(""); + let state = typed::json_string(arm, "state").unwrap_or("unknown"); + let server_message = typed::json_string(arm, "server_message") + .map(|value| format!("; server message {value}")) + .unwrap_or_default(); + let detail = typed::json_string(arm, "detail") + .map(|value| format!("; detail {value}")) + .unwrap_or_default(); + let additional = additional_json( + arm, + &[ + "url", + "state", + "server_message", + "detail", + "rail", + "reconnect", + "failures", + "max_failures", + "next_in_s", + "last_failure", + "reason", + "at", + ], + ) + .map(|value| format!("; additional {value}")) + .unwrap_or_default(); + lines.push(fields::render_dynamic_field( + format!("Watch arm {}", index + 1), + format!("{url} — {state}{server_message}{detail}{additional}"), )); } } @@ -534,7 +713,7 @@ mod tests { "futureInput": {"enabled": true} }), Some( - r#"{"title":"Dashboard","url":"https://artifact.local/dashboard","path":"C:/work/dashboard.html","version":"v3","artifact_id":"artifact-42","capabilities":{"storage":true},"stored":{"contract":"artifact-v1","capabilities":{"persist":true}},"warnings":["legacy contract"],"contract":"artifact-v2","updated":true,"liveSubscription":"subscription-42","futureOutput":{"revision":4}}"#, + r#"{"title":"Dashboard","url":"https://artifact.local/dashboard","path":"C:/work/dashboard.html","version":"v3","artifact_id":"artifact-42","capabilities":{"storage":true},"stored":{"contract":"artifact-v1","preferredContract":"artifact-v2","capabilities":{"persist":true},"carried":true,"read":"v3","futureStored":4},"warnings":["legacy contract"],"contract":"artifact-v2","updated":true,"audience":"workspace","liveSubscription":"subscription-42","futureOutput":{"revision":4}}"#, ), ); @@ -556,9 +735,14 @@ mod tests { "Capabilities: {\"storage\":true}", "Stored contract: artifact-v1", "Stored capabilities: {\"persist\":true}", + "Preferred contract: artifact-v2", + "Stored state carried: yes", + "Stored read state: v3", + "Additional stored output: {\"futureStored\":4}", "Warnings: legacy contract", "Contract: artifact-v2", "Updated: yes", + "Audience: workspace", "Live subscription: subscription-42", "Additional output: {\"futureOutput\":{\"revision\":4}}", ] @@ -570,7 +754,7 @@ mod tests { let tc = artifact_tool_call( json!({"action": "list", "scope": "all", "limit": 10}), Some( - r#"{"scope":"all","truncated":false,"artifacts":[{"rel":"mine","title":"Dashboard","url":"https://artifact.local/dashboard","updatedAt":"2026-07-18T10:00:00Z"},{"rel":"shared","title":"Roadmap","url":"https://artifact.local/roadmap"}]}"#, + r#"{"scope":"all","truncated":false,"artifacts":[{"rel":"mine","title":"Dashboard","url":"https://artifact.local/dashboard","favicon":"📊","updatedAt":"2026-07-18T10:00:00Z","futureItem":true},{"rel":"shared","title":"Roadmap","url":"https://artifact.local/roadmap"}]}"#, ), ); @@ -585,7 +769,7 @@ mod tests { "Scope: all", "Truncated: no", "Artifacts: 2", - "Artifact 1: (mine) Dashboard — https://artifact.local/dashboard — updated 2026-07-18T10:00:00Z", + "Artifact 1: (mine) Dashboard — https://artifact.local/dashboard — icon 📊 — updated 2026-07-18T10:00:00Z — additional {\"futureItem\":true}", "Artifact 2: (shared) Roadmap — https://artifact.local/roadmap", ] ); @@ -754,7 +938,7 @@ mod tests { let tc = artifact_tool_call( json!({"action": "watch", "url": "https://artifact.local/dashboard"}), Some( - r#"{"watch":{"url":"https://artifact.local/dashboard","watching":false,"outcome":"unsupported_here"}}"#, + r#"{"watch":{"url":"https://artifact.local/dashboard","watching":false,"outcome":"unsupported_here","detail":"runtime unavailable","futureWatch":true}}"#, ), ); @@ -763,8 +947,29 @@ mod tests { vec![ "Action: watch", "URL: https://artifact.local/dashboard", - "Additional output: {\"watch\":{\"outcome\":\"unsupported_here\",\"url\":\"https://artifact.local/dashboard\",\"watching\":false}}", + "Watch URL: https://artifact.local/dashboard", + "Watch outcome: unsupported_here", + "Watch detail: runtime unavailable", + "Watching: no", + "Additional watch output: {\"futureWatch\":true}", ] ); } + + #[test] + fn renders_watcher_status_and_server_details() { + let tc = artifact_tool_call( + json!({"action": "status"}), + Some( + r#"{"filter_url":"https://artifact.local/dashboard","watches":[{"url":"https://artifact.local/dashboard","rail":"durable_wake","trigger_id":"trigger-1","since":"2026-09-02","events":["published"],"futureWatcher":true}],"arms":[{"url":"https://artifact.local/dashboard","state":"retrying","server_message":"try later","detail":"connection reset","futureArm":true}]}"#, + ), + ); + let rendered = rendered_line_texts(&render_tool_content(&tc)); + + assert!(rendered.contains(&"Watch filter: https://artifact.local/dashboard".to_owned())); + assert!(rendered.iter().any(|line| line.contains("futureWatcher"))); + assert!(rendered.iter().any(|line| line.contains("server message try later"))); + assert!(rendered.iter().any(|line| line.contains("detail connection reset"))); + assert!(rendered.iter().any(|line| line.contains("futureArm"))); + } } diff --git a/src/ui/tool_call/repl.rs b/src/ui/tool_call/repl.rs index 3268d662..b678440b 100644 --- a/src/ui/tool_call/repl.rs +++ b/src/ui/tool_call/repl.rs @@ -111,6 +111,12 @@ fn field_label(label: &str) -> Option<&'static str> { "Registered tools" | "registeredTools" => Some("Registered tools"), "Images" | "images" => Some("Images"), "Documents" | "documents" => Some("Documents"), + "Images omitted" | "imagesOmitted" => Some("Images omitted"), + "Failed image page" | "imagePagesFailed" => Some("Failed image page"), + "Failed image pages omitted" | "imagePagesFailedOmitted" => { + Some("Failed image pages omitted") + } + "Documents omitted" | "documentsOmitted" => Some("Documents omitted"), _ => None, } } @@ -118,7 +124,11 @@ fn field_label(label: &str) -> Option<&'static str> { fn field_value(label: &str, value: &str) -> String { match label { "Registered tools" => registered_tools_value(value), - "Images" | "Documents" => media_count_value(value), + "Images" + | "Documents" + | "Images omitted" + | "Failed image pages omitted" + | "Documents omitted" => media_count_value(value), _ => value.to_owned(), } } @@ -292,4 +302,25 @@ mod tests { assert_eq!(body.len(), super::super::TOOL_BODY_MAX_LINES); assert!(rendered.iter().any(|line| line.contains("hidden"))); } + + #[test] + fn omission_and_failed_page_fields_render_as_structured_rows() { + let tc = repl_tool_call( + json!({}), + Some( + "Images omitted: 3\nFailed image page: 4 (file manual.pdf: render failed)\nFailed image pages omitted: 2\nDocuments omitted: 1", + ), + model::ToolCallStatus::Completed, + ); + + assert_eq!( + rendered_line_texts(&render_tool_content(&tc)), + vec![ + "Images omitted: 3", + "Failed image page: 4 (file manual.pdf: render failed)", + "Failed image pages omitted: 2", + "Documents omitted: 1", + ] + ); + } } diff --git a/src/ui/tool_call/schedule_wakeup.rs b/src/ui/tool_call/schedule_wakeup.rs index 9461d160..f1adbede 100644 --- a/src/ui/tool_call/schedule_wakeup.rs +++ b/src/ui/tool_call/schedule_wakeup.rs @@ -31,6 +31,10 @@ fn render_input_content(tc: &ToolCallInfo, include_requested_delay: bool) -> Vec let mut wakeup_fields = Vec::new(); if let Some(input) = input { + if typed::json_bool(input, "stop") == Some(true) { + wakeup_fields.push(ToolField::new("Stop", "yes")); + return fields::render_fields(wakeup_fields); + } if include_requested_delay && let Some(delay_seconds) = typed::json_i64(input, "delaySeconds") { @@ -45,6 +49,9 @@ fn render_input_content(tc: &ToolCallInfo, include_requested_delay: bool) -> Vec if let Some(prompt) = typed::json_string(input, "prompt") { wakeup_fields.push(ToolField::new("Prompt", prompt)); } + if let Some(noop) = typed::json_bool(input, "noop") { + wakeup_fields.push(ToolField::new("No changes", typed::bool_label(noop))); + } } fields::render_fields(wakeup_fields) @@ -85,6 +92,8 @@ fn field_label(label: &str) -> Option<&'static str> { "Clamped" | "wasClamped" => Some("Clamped"), "Reason" => Some("Reason"), "Prompt" => Some("Prompt"), + "Stop" | "stop" => Some("Stop"), + "No changes" | "noop" => Some("No changes"), _ => None, } } @@ -94,7 +103,9 @@ fn field_value(label: &str, value: &str) -> String { "Actual delay" => { value.parse::().map_or_else(|_| value.to_owned(), typed::format_duration_seconds) } - "Clamped" => typed::bool_text_label(value).unwrap_or(value).to_owned(), + "Clamped" | "Stop" | "No changes" => { + typed::bool_text_label(value).unwrap_or(value).to_owned() + } _ => value.to_owned(), } } @@ -191,4 +202,37 @@ mod tests { assert!(rendered.iter().any(|line| line.contains("Requested delay: 1h"))); assert!(rendered.iter().any(|line| line.contains("Could not schedule wakeup"))); } + + #[test] + fn input_body_renders_noop_changed_and_stop_states() { + for (noop, expected) in [(true, "yes"), (false, "no")] { + let tc = schedule_wakeup_tool_call( + json!({ + "delaySeconds": 60, + "reason": "Check again", + "prompt": "/loop continue", + "noop": noop + }), + None, + model::ToolCallStatus::InProgress, + ); + assert!( + rendered_line_texts(&render_tool_content(&tc)) + .contains(&format!("No changes: {expected}")) + ); + } + + let stopped = schedule_wakeup_tool_call( + json!({ + "stop": true, + "delaySeconds": 60, + "reason": "ignored", + "prompt": "ignored", + "noop": true + }), + None, + model::ToolCallStatus::InProgress, + ); + assert_eq!(rendered_line_texts(&render_tool_content(&stopped)), vec!["Stop: yes"]); + } } diff --git a/src/ui/tool_call/standard.rs b/src/ui/tool_call/standard.rs index 81397e63..ccfaeb63 100644 --- a/src/ui/tool_call/standard.rs +++ b/src/ui/tool_call/standard.rs @@ -261,6 +261,9 @@ pub(super) fn content_summary(tc: &ToolCallInfo) -> String { } return resource.uri.clone(); } + model::ToolCallContent::ResourceLink(link) => { + return link.title.as_deref().unwrap_or(&link.name).to_owned(); + } model::ToolCallContent::Content(c) => { if let model::ContentBlock::Text(text) = &c.content { let stripped = strip_outer_code_fence(&text.text); @@ -338,6 +341,9 @@ fn render_tool_content(tc: &ToolCallInfo, width: u16) -> Vec> { model::ToolCallContent::McpResource(resource) => { lines.extend(render_mcp_resource_content(tc, resource)); } + model::ToolCallContent::ResourceLink(link) => { + lines.extend(render_mcp_resource_link_content(link)); + } model::ToolCallContent::Content(c) => { if let model::ContentBlock::Text(text) = &c.content { render_text_content(tc, &text.text, &mut lines); @@ -543,6 +549,7 @@ fn renders_only_plan_file_content(tc: &ToolCallInfo) -> bool { model::ToolCallContent::Terminal(_) => {} model::ToolCallContent::Diff(_) | model::ToolCallContent::McpResource(_) + | model::ToolCallContent::ResourceLink(_) | model::ToolCallContent::Content(_) => return false, } } @@ -557,6 +564,8 @@ fn is_read_tool(tc: &ToolCallInfo) -> bool { fn protected_content_source_lines(tc: &ToolCallInfo, lines: &[Line<'static>]) -> usize { let mut count = if is_read_tool(tc) { READ_BODY_HEAD_LINES + } else if matches!(tc.content.first(), Some(model::ToolCallContent::ResourceLink(_))) { + 2 } else { leading_diff_metadata_line_count(lines) } @@ -848,6 +857,50 @@ fn render_mcp_resource_content( lines } +fn render_mcp_resource_link_content(link: &model::McpResourceLink) -> Vec> { + let mut lines = vec![Line::from(vec![ + Span::styled("Resource: ", Style::default().fg(theme::DIM).add_modifier(Modifier::BOLD)), + Span::raw(link.title.as_deref().unwrap_or(&link.name).to_owned()), + ])]; + lines.push(Line::from(vec![ + Span::styled("URI: ", Style::default().fg(theme::DIM).add_modifier(Modifier::BOLD)), + Span::raw(link.uri.clone()), + ])); + if let Some(description) = &link.description { + lines.push(Line::from(vec![ + Span::styled( + "Description: ", + Style::default().fg(theme::DIM).add_modifier(Modifier::BOLD), + ), + Span::raw(description.clone()), + ])); + } + if let Some(mime_type) = &link.mime_type { + lines.push(Line::from(vec![ + Span::styled("Type: ", Style::default().fg(theme::DIM).add_modifier(Modifier::BOLD)), + Span::raw(mime_type.clone()), + ])); + } + if let Some(size) = link.size { + lines.push(Line::from(vec![ + Span::styled("Size: ", Style::default().fg(theme::DIM).add_modifier(Modifier::BOLD)), + Span::raw(format!("{size} bytes")), + ])); + } + if let Some(annotations) = &link.annotations + && let Ok(value) = serde_json::to_string(annotations) + { + lines.push(Line::from(vec![ + Span::styled( + "Annotations: ", + Style::default().fg(theme::DIM).add_modifier(Modifier::BOLD), + ), + Span::raw(value), + ])); + } + lines +} + fn indent_rendered_lines(lines: Vec>, indent: &str) -> Vec> { lines .into_iter() diff --git a/src/ui/tool_call/tests.rs b/src/ui/tool_call/tests.rs index 58406617..40c7047c 100644 --- a/src/ui/tool_call/tests.rs +++ b/src/ui/tool_call/tests.rs @@ -1073,6 +1073,42 @@ fn mcp_resource_body_avoids_duplicate_saved_path_hint_when_text_already_mentions ); } +#[test] +fn mcp_resource_links_render_summary_and_bounded_body_at_narrow_and_wide_widths() { + let mut tc = + test_tool_call("tc-resource-link", "mcp__docs__export", model::ToolCallStatus::Completed); + tc.content = vec![model::ToolCallContent::ResourceLink( + model::McpResourceLink::new("mcp://docs/report.csv", "report.csv") + .title(Some("Quarterly report".to_owned())) + .description(Some("A generated report for the current quarter".to_owned())) + .mime_type(Some("text/csv".to_owned())) + .size(Some(42)) + .annotations(Some(std::collections::BTreeMap::from([( + "audience".to_owned(), + serde_json::json!(["user"]), + )]))), + )]; + + assert_eq!(standard::content_summary(&tc), "Quarterly report"); + for width in [24, 120] { + let body = standard::render_tool_call_body(&tc, width); + let rendered_lines = rendered_line_texts(&body); + let rendered = rendered_lines.join("\n"); + let compact = rendered_lines + .iter() + .flat_map(|line| line.chars().skip(5)) + .filter(|character| !character.is_whitespace()) + .collect::(); + assert!(compact.contains("Resource:Quarterlyreport"), "{rendered}"); + assert!(compact.contains("URI:mcp://docs/report.csv"), "{rendered}"); + if width == 120 { + assert!(rendered.contains("text/csv")); + assert!(rendered.contains("42 bytes")); + } + assert!(body.len() <= TOOL_BODY_MAX_LINES); + } +} + #[test] fn read_tool_renders_head_hidden_marker_and_tail() { let mut tc = test_tool_call("tc-read-body", "Read", model::ToolCallStatus::Completed); From 3b0a432000d8736b8f29490c642814342f2cea30 Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Wed, 2 Sep 2026 20:47:11 +0200 Subject: [PATCH 4/4] docs(changelog): add migration and user message queue to changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ddcc6e..32b865bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Features + +- **Active-turn message queue** (#363, @srothgan): Upgrade to Agent SDK `0.3.258` and queue up to ten messages with correlated chat insertion. + ### Fixes - **Responsive startup and file indexing** (#352, @srothgan): Keep the composer editable while connecting, defer sends until the session is ready, and run one bounded post-connection file index.