diff --git a/CHANGELOG.md b/CHANGELOG.md index 0181ac06..b183fc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,15 @@ All notable changes to this project will be documented in this file. - +## [Unreleased] + +### Features + +- **Agent SDK 0.3.270 migration** (#378, @srothgan): Upgrade to Agent SDK `0.3.270`, honor permission safety hints and SDK-controlled Task and Todo availability, hold cache-invalidating plugin reloads behind explicit confirmation, and render staged writes, project memory, and expanded Artifact metadata. + +### Fixes + +- **Prompt suggestion lifecycle** (#377, @srothgan): Clear the SDK prompt suggestion only after a prompt is dispatched or queued, so local slash commands, rejected submissions, and send failures keep it visible. ## [0.14.6] - 2026-09-02 [Changes][v0.14.6] diff --git a/agent-sdk/package-lock.json b/agent-sdk/package-lock.json index 80230664..e6955a58 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.258" + "@anthropic-ai/claude-agent-sdk": "0.3.270" }, "devDependencies": { "@biomejs/biome": "2.5.12", @@ -22,22 +22,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.270.tgz", + "integrity": "sha512-sSfcm5Nhb+WHeBCxqeHRRQMUKPmFTL+zgv5xcRUVaFMLttfNEbn3IZJE+fLJJmy4h3J8zdc5sXdSa1JxyB8ppQ==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@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" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.270", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.270" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -46,9 +46,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.270.tgz", + "integrity": "sha512-nk7BP+i559rheYz9DIwAfevd4DulQXP0mXPP+MeO2fGuIGFmzhE/c0JRm9YswXv5HdaYJvSzjGIB7dVA01NehA==", "cpu": [ "arm64" ], @@ -59,9 +59,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.270.tgz", + "integrity": "sha512-89Uql8Oalm52ojdZZeNLU24LKrU+WG9QR7d6YP9ly4aY0YvQUJDaTosbDkijQngUETbPBFdKuVp6fNM8x0Zt3Q==", "cpu": [ "x64" ], @@ -72,9 +72,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.270.tgz", + "integrity": "sha512-iHPYqwetyeO4tZPzXyKZz0hUh2fLpwu/+biGTxxynikG3XrYknovZ/znGDA3TxjSFurqHf5IIDA+SOjh9OPs0A==", "cpu": [ "arm64" ], @@ -88,9 +88,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.270.tgz", + "integrity": "sha512-2BlLk2MAohWG2h43RKcjCA4ooMfBxzKf4yyYfOVv1DtYr8zPU876MHCT1VXB2BaemjKA0pdYJJBzH6pncwx6MQ==", "cpu": [ "arm64" ], @@ -104,9 +104,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.270.tgz", + "integrity": "sha512-ADaqz2viyAd0GUxdupYLX/K0YJb46xckNpEeWxyLK/9+26b/R5stbaGDyL29fIzyq1ymnNUOaCgEWEemgX0kEA==", "cpu": [ "x64" ], @@ -120,9 +120,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.270.tgz", + "integrity": "sha512-mzH3lnbzrbDGrTf75jLEmkbvkKRLLgmjLaWvf3QuUsgcw+aU69aOY0mW33oOrsuq5zg330uI3B4e68f4LbxNIA==", "cpu": [ "x64" ], @@ -136,9 +136,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.270.tgz", + "integrity": "sha512-Pexeu26cLZByhs6VlrawNYAEu+QE2YptvwNkXsmpLRm7Q/C/M0N/BZBmuUcikXrQQ9cuVzSjuuTTpZt64mvtLA==", "cpu": [ "arm64" ], @@ -149,9 +149,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.270.tgz", + "integrity": "sha512-9UyfFcUYsyUZqSe/xX9nIJ1Og6i8FxhlQ35BDi79Ik5He87XFxEIGUvJuMcl7Mq2e3panyhekELFE+9H79xKdw==", "cpu": [ "x64" ], diff --git a/agent-sdk/package.json b/agent-sdk/package.json index 713050d3..058c1e3c 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.258" + "@anthropic-ai/claude-agent-sdk": "0.3.270" }, "devDependencies": { "@biomejs/biome": "2.5.12", diff --git a/agent-sdk/src/bridge.test.ts b/agent-sdk/src/bridge.test.ts index e9e5b051..d4a9bfc4 100644 --- a/agent-sdk/src/bridge.test.ts +++ b/agent-sdk/src/bridge.test.ts @@ -1415,6 +1415,82 @@ test("parseCommandEnvelope validates reload_plugins command", () => { command: "reload_plugins", session_id: "session-123", }); + + assert.deepEqual( + parseCommandEnvelope( + JSON.stringify({ + command: "reload_plugins", + session_id: "session-123", + force: true, + }), + ).command, + { command: "reload_plugins", session_id: "session-123", force: true }, + ); +}); + +test("handleReloadPluginsCommand holds cache-impacting reloads and sanitizes plugin names", async () => { + const session = makeSessionState(); + const optionsSeen: unknown[] = []; + session.query = { + reloadPlugins: async (options: unknown) => { + optionsSeen.push(options); + return { + commands: [], + agents: [], + plugins: [], + mcpServers: [], + error_count: 0, + held: true, + cache_impact: { + mcp_servers_added: ["plugin:docs:search", "unscoped", "plugin:bad:\u202eline"], + mcp_servers_removed: ["plugin:old:server"], + lsp_tool_change: "adds", + }, + }; + }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").Query; + + const events = await captureBridgeEventsAsync(async () => { + await handleReloadPluginsCommand(session, "req-held"); + }); + + assert.deepEqual(optionsSeen, [{ holdOnCacheImpact: true }]); + assert.deepEqual(events, [ + { + request_id: "req-held", + event: "runtime_reload_held", + session_id: "session-1", + cache_impact: { + mcp_servers_added: ["plugin:docs:search"], + mcp_servers_removed: ["plugin:old:server"], + lsp_tool_change: "adds", + invalid_server_name_count: 2, + }, + }, + ]); +}); + +test("handleReloadPluginsCommand forces only through an optionless explicit request", async () => { + const session = makeSessionState(); + const argumentLists: unknown[][] = []; + session.query = { + reloadPlugins: async (...args: unknown[]) => { + argumentLists.push(args); + return { + commands: [], + agents: [], + plugins: [], + mcpServers: [], + error_count: 0, + }; + }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").Query; + + await captureBridgeEventsAsync(async () => { + await handleReloadPluginsCommand(session, "req-force", true); + }); + + assert.deepEqual(argumentLists, [[]]); }); test("handleReloadPluginsCommand emits MCP snapshot from reload result", async () => { @@ -2438,7 +2514,7 @@ test("buildQueryOptions enables dangerous skip flag for bypass permissions start assert.equal(options.permissionMode, "bypassPermissions"); assert.equal(options.allowDangerouslySkipPermissions, true); - assert.equal("canUseTool" in options, false); + assert.equal("canUseTool" in options, true); }); test("buildQueryOptions omits optional startup overrides but keeps bridge guard prompt", () => { @@ -2679,7 +2755,7 @@ test("buildQueryOptions forwards SDK-provided spawn env without passing top-leve } }); -test("buildQueryOptions defaults Todo tools without overriding explicit SDK child env", async () => { +test("buildQueryOptions leaves Todo tool availability under SDK environment authority", async () => { for (const explicit of [undefined, "0", "1"] as const) { const input = new AsyncQueue< import("@anthropic-ai/claude-agent-sdk").SDKUserMessage @@ -2717,7 +2793,7 @@ test("buildQueryOptions defaults Todo tools without overriding explicit SDK chil child.on("exit", resolve); }); assert.equal(exitCode, 0); - assert.equal(stdout, explicit ?? "1"); + assert.equal(stdout, explicit ?? "missing"); } }); @@ -3669,6 +3745,27 @@ test("handleSdkMessage emits main-thread user-message start before streamed cont assert.equal(events[1]?.event, "session_update"); }); +test("handleSdkMessage emits bounded deduplicated starts for plural user message UUIDs", () => { + const session = makeSessionState(); + const uuids = Array.from({ length: 70 }, (_, index) => `user-${index}`); + const events = captureBridgeEvents(() => { + handleSdkMessage(session, { + type: "assistant", + uuid: "assistant-complete", + session_id: "session-1", + parent_tool_use_id: null, + user_message_uuids: [uuids[0], uuids[0], ...uuids], + user_message_uuid: "legacy-fallback", + message: { role: "assistant", content: [] }, + } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + }); + + const starts = events.filter((event) => event.event === "user_message_started"); + assert.equal(starts.length, 64); + assert.equal(starts[0]?.message_uuid, "user-0"); + assert.equal(starts.at(-1)?.message_uuid, "user-63"); +}); + test("direct user MCP result appends resource links without replacing text", () => { const session = makeSessionState(); const toolCall = createToolCall("tool-mcp", "mcp__docs__export", {}); @@ -3930,6 +4027,26 @@ test("handleSdkMessage uses assistant and result as correlation fallbacks and fo ]); }); +test("handleResultMessage treats permission denials as authoritative for open tools", () => { + const session = makeSessionState(); + captureBridgeEvents(() => { + emitToolCall(session, "tool-denied", "Write", { file_path: "a.txt", content: "x" }); + emitToolCall(session, "tool-finished", "Read", { file_path: "b.txt" }); + emitToolResultUpdate(session, "tool-finished", false, "done"); + handleResultMessage(session, { + type: "result", + subtype: "success", + permission_denials: [ + { tool_name: "Write", tool_use_id: "tool-denied", tool_input: {} }, + { tool_name: "Write", tool_use_id: "tool-denied", tool_input: {} }, + ], + }); + }); + + assert.equal(session.toolCalls.get("tool-denied")?.status, "failed"); + assert.equal(session.toolCalls.get("tool-finished")?.status, "completed"); +}); + test("handleSdkMessage does not correlate subagent reply frames", () => { const session = makeSessionState(); const events = captureBridgeEvents(() => { @@ -6347,6 +6464,7 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => { resetsAt: 1_741_280_000, utilization: 0.92, rateLimitType: "five_hour", + limitScope: "group_pool", overageStatus: "rejected", overageResetsAt: 1_741_280_600, overageDisabledReason: "out_of_credits", @@ -6364,6 +6482,7 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => { resets_at: 1_741_280_000, utilization: 0.92, rate_limit_type: "five_hour", + limit_scope: "group_pool", overage_status: "rejected", overage_resets_at: 1_741_280_600, overage_disabled_reason: "out_of_credits", @@ -8090,6 +8209,44 @@ test("permissionOptionsFromSuggestions uses persistent label when settings scope ]); }); +test("permissionOptionsFromSuggestions suppresses persistent allow when requested", () => { + const options = permissionOptionsFromSuggestions( + [ + { + type: "addRules", + behavior: "allow", + destination: "localSettings", + rules: [{ toolName: "Bash" }], + }, + ], + true, + ); + assert.deepEqual(options, [ + { option_id: "allow_once", name: "Allow once", kind: "allow_once" }, + { option_id: "reject_once", name: "Deny", kind: "reject_once" }, + ]); +}); + +test("permissionOptionsFromSuggestions retains session approval while suppressing mixed persistent rules", () => { + const options = permissionOptionsFromSuggestions( + [ + { type: "setMode", mode: "acceptEdits", destination: "session" }, + { + type: "addRules", + behavior: "allow", + destination: "localSettings", + rules: [{ toolName: "Bash" }], + }, + ], + true, + ); + assert.deepEqual(options, [ + { option_id: "allow_once", name: "Allow once", kind: "allow_once" }, + { option_id: "allow_session", name: "Allow for session", kind: "allow_session" }, + { option_id: "reject_once", name: "Deny", kind: "reject_once" }, + ]); +}); + test("permissionResultFromOutcome keeps Bash allow_always suggestions unchanged", () => { const allow = permissionResultFromOutcome( { outcome: "selected", option_id: "allow_always" }, @@ -8211,7 +8368,7 @@ test("looksLikeAuthRequired detects login hints", () => { }); test("agent sdk version compatibility check matches pinned version", () => { - assert.equal(resolveInstalledAgentSdkVersion(), "0.3.258"); + assert.equal(resolveInstalledAgentSdkVersion(), "0.3.270"); assert.equal(agentSdkVersionCompatibilityError(), undefined); }); @@ -9156,9 +9313,11 @@ test("commitDeferredSession publishes connect before all buffered session-scoped test("handleResultMessage emits typed turn error classifications for SDK assistant errors", () => { const cases = [ ["account_on_hold", "account_access"], + ["cloud_credential_error", "account_access"], ["model_not_found", "model_unavailable"], ["oauth_org_not_allowed", "account_access"], ["overloaded", "transient_service"], + ["verification_required", "auth_required"], ] as const; for (const [assistantError, errorKind] of cases) { diff --git a/agent-sdk/src/bridge.ts b/agent-sdk/src/bridge.ts index b0e983cd..9c92feb1 100644 --- a/agent-sdk/src/bridge.ts +++ b/agent-sdk/src/bridge.ts @@ -26,6 +26,7 @@ import { failConnection, slashError, emitRuntimeReloadCompleted, + emitRuntimeReloadHeld, emitRuntimeReloadFailed, emitSessionUpdate, setSessionListingDir, @@ -358,7 +359,7 @@ export function emitAgentConfigOptionUpdate( }); } -const EXPECTED_AGENT_SDK_VERSION = "0.3.258"; +const EXPECTED_AGENT_SDK_VERSION = "0.3.270"; const require = createRequire(import.meta.url); export function resolveInstalledAgentSdkVersion(): string | undefined { @@ -394,9 +395,20 @@ export function agentSdkVersionCompatibilityError(): string | undefined { export async function handleReloadPluginsCommand( session: SessionState, requestId?: string, + force = false, ): Promise { try { - const result = await session.query.reloadPlugins(); + const result = force + ? await session.query.reloadPlugins() + : await session.query.reloadPlugins({ holdOnCacheImpact: true }); + if (!force && result.held === true) { + emitRuntimeReloadHeld( + session.sessionId, + sanitizeRuntimeReloadCacheImpact(result.cache_impact), + requestId, + ); + return; + } updateAvailableCommands( session, "reload_plugins", @@ -425,6 +437,59 @@ export async function handleReloadPluginsCommand( } } +const MAX_CACHE_IMPACT_SERVER_NAMES = 64; +const MAX_CACHE_IMPACT_SERVER_NAME_LENGTH = 256; +const UNSAFE_SERVER_NAME_CHARACTER = /[\p{Cc}\p{Cf}\p{Cs}\p{Z}]/u; + +function sanitizeRuntimeReloadCacheImpact( + value: unknown, +): import("./types.js").RuntimeReloadCacheImpact { + const impact = value && typeof value === "object" + ? (value as Record) + : {}; + let invalidServerNameCount = 0; + const sanitizeNames = (names: unknown): string[] => { + if (!Array.isArray(names)) { + return []; + } + const safe: string[] = []; + const seen = new Set(); + for (const value of names) { + const valid = + typeof value === "string" && + value.length <= MAX_CACHE_IMPACT_SERVER_NAME_LENGTH && + !UNSAFE_SERVER_NAME_CHARACTER.test(value) && + /^plugin:[^:]+:[^:]+$/.test(value); + if (!valid) { + invalidServerNameCount += 1; + continue; + } + if (seen.has(value)) { + continue; + } + if (safe.length >= MAX_CACHE_IMPACT_SERVER_NAMES) { + invalidServerNameCount += 1; + continue; + } + seen.add(value); + safe.push(value); + } + return safe; + }; + const lspToolChange = impact.lsp_tool_change; + return { + mcp_servers_added: sanitizeNames(impact.mcp_servers_added), + mcp_servers_removed: sanitizeNames(impact.mcp_servers_removed), + ...(lspToolChange === "adds" || + lspToolChange === "may-add" || + lspToolChange === "removes" || + lspToolChange === "may-remove" + ? { lsp_tool_change: lspToolChange } + : {}), + invalid_server_name_count: invalidServerNameCount, + }; +} + type ResolvedRewindTarget = { inputText: string; previousAssistantUuid?: string; diff --git a/agent-sdk/src/bridge/command_session_control.ts b/agent-sdk/src/bridge/command_session_control.ts index 5376b5b3..1aff5511 100644 --- a/agent-sdk/src/bridge/command_session_control.ts +++ b/agent-sdk/src/bridge/command_session_control.ts @@ -56,6 +56,7 @@ export type SessionControlCommandDeps = { handleReloadPluginsCommand: ( session: SessionState, requestId?: string, + force?: boolean, ) => Promise; }; @@ -424,7 +425,7 @@ async function reloadPlugins( ): Promise { const session = requireSession(command.session_id, requestId); if (session) { - await deps.handleReloadPluginsCommand(session, requestId); + await deps.handleReloadPluginsCommand(session, requestId, command.force); } } diff --git a/agent-sdk/src/bridge/commands.ts b/agent-sdk/src/bridge/commands.ts index 14eabc49..790d379a 100644 --- a/agent-sdk/src/bridge/commands.ts +++ b/agent-sdk/src/bridge/commands.ts @@ -502,6 +502,9 @@ export function parseCommandEnvelope(line: string): { return { command: "reload_plugins", session_id: expectString(raw, "session_id", "reload_plugins"), + ...(optionalBoolean(raw, "force", "reload_plugins") !== undefined + ? { force: optionalBoolean(raw, "force", "reload_plugins") } + : {}), }; case "mcp_status": // Rust historically sends `get_mcp_snapshot`; normalize it to the bridge-internal command. diff --git a/agent-sdk/src/bridge/error_classification.ts b/agent-sdk/src/bridge/error_classification.ts index 7d9431ef..e102f8bc 100644 --- a/agent-sdk/src/bridge/error_classification.ts +++ b/agent-sdk/src/bridge/error_classification.ts @@ -54,9 +54,11 @@ export function classifyTurnErrorKind( case "rate_limit": return "plan_limit"; case "authentication_failed": + case "verification_required": return "auth_required"; case "account_on_hold": case "oauth_org_not_allowed": + case "cloud_credential_error": return "account_access"; case "model_not_found": return "model_unavailable"; diff --git a/agent-sdk/src/bridge/events.ts b/agent-sdk/src/bridge/events.ts index 03392a2a..73372ca8 100644 --- a/agent-sdk/src/bridge/events.ts +++ b/agent-sdk/src/bridge/events.ts @@ -137,6 +137,17 @@ export function emitRuntimeReloadCompleted( ); } +export function emitRuntimeReloadHeld( + sessionId: string, + cacheImpact: import("../types.js").RuntimeReloadCacheImpact, + requestId?: string, +): void { + writeEvent( + { event: "runtime_reload_held", session_id: sessionId, cache_impact: cacheImpact }, + requestId, + ); +} + export function emitRuntimeReloadFailed( sessionId: string, message: string, diff --git a/agent-sdk/src/bridge/logger.ts b/agent-sdk/src/bridge/logger.ts index 08a1a137..c7dd3ab0 100644 --- a/agent-sdk/src/bridge/logger.ts +++ b/agent-sdk/src/bridge/logger.ts @@ -192,6 +192,7 @@ function eventToolCallId(event: BridgeEvent): string | undefined { case "slash_error": case "session_resume_failed": case "runtime_reload_completed": + case "runtime_reload_held": case "runtime_reload_failed": case "session_replaced": case "initialized": @@ -234,6 +235,7 @@ function protocolEventLevel(event: BridgeEvent): LogLevel { case "slash_error": case "session_resume_failed": case "runtime_reload_failed": + case "runtime_reload_held": return "warn"; case "session_update": case "permission_request": diff --git a/agent-sdk/src/bridge/message_handlers.ts b/agent-sdk/src/bridge/message_handlers.ts index a15f15bd..be2cf795 100644 --- a/agent-sdk/src/bridge/message_handlers.ts +++ b/agent-sdk/src/bridge/message_handlers.ts @@ -72,6 +72,7 @@ import { buildSubagentRetryUpdate, normalizeSettingsParseErrors, nonNegativeIntegerField, + nonNegativeNumberField, numberField, parseApiRetryError, parseRuntimeSessionState, @@ -239,6 +240,9 @@ export function sdkMessageDiagnosticFields( sdk_subtype: typeof msg.subtype === "string" ? msg.subtype : undefined, sdk_uuid: trimmedStringField(msg, "uuid"), user_message_uuid: trimmedStringField(msg, "user_message_uuid"), + ...(userMessageUuids(msg).length > 0 + ? { user_message_uuids: userMessageUuids(msg) } + : {}), parent_tool_use_id: parentToolUseId, parent_tool_use_scope: !hasParentToolUseId ? "absent" @@ -255,6 +259,15 @@ export function sdkMessageDiagnosticFields( lifecycle_state: diagnosticToken(msg.state), lifecycle_operation: diagnosticToken(msg.operation), lifecycle_reason: diagnosticToken(msg.reason), + ...(diagnosticToken(msg.resume_reason) + ? { resume_reason: diagnosticToken(msg.resume_reason) } + : {}), + ...(nonNegativeIntegerField(msg, "result_index") !== undefined + ? { result_index: nonNegativeIntegerField(msg, "result_index") } + : {}), + ...(boundedDiagnosticString(msg.local_command) + ? { local_command: boundedDiagnosticString(msg.local_command) } + : {}), sdk_keys: Object.keys(msg).sort(), ...(Object.keys(uuidFields).length > 0 ? { sdk_uuid_fields: uuidFields } @@ -461,6 +474,14 @@ function trimmedStringField( return trimmed ? trimmed : undefined; } +function boundedDiagnosticString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, 1_024) : undefined; +} + function ensureSentencePunctuation(value: string): string { const trimmed = value.trim(); if (!trimmed) { @@ -1288,11 +1309,83 @@ function emitUserMessageStarted( message: Record, source: "stream_event" | "assistant" | "result", ): void { - const messageUuid = trimmedStringField(message, "user_message_uuid"); - if (!messageUuid) { - return; + for (const messageUuid of userMessageUuids(message)) { + emitUserMessageStartedForUuid(session, messageUuid, source); + } +} + +const MAX_USER_MESSAGE_UUIDS = 64; + +export function userMessageUuids(message: Record): string[] { + const uuids: string[] = []; + const seen = new Set(); + const rawUuids = message.user_message_uuids; + if (Array.isArray(rawUuids)) { + for (const value of rawUuids) { + if (typeof value !== "string") { + continue; + } + const uuid = value.trim(); + if (!uuid || seen.has(uuid)) { + continue; + } + seen.add(uuid); + uuids.push(uuid); + if (uuids.length >= MAX_USER_MESSAGE_UUIDS) { + break; + } + } + } + if (uuids.length === 0) { + const fallback = trimmedStringField(message, "user_message_uuid"); + if (fallback) { + uuids.push(fallback); + } + } + return uuids; +} + +function reconcileResultPermissionDenials( + session: SessionState, + message: Record, +): void { + const denials = Array.isArray(message.permission_denials) + ? message.permission_denials.slice(0, 256) + : []; + const seen = new Set(); + let reconciled = 0; + for (const value of denials) { + const denial = asRecordOrNull(value); + const toolUseId = denial + ? trimmedStringField(denial, "tool_use_id") + : undefined; + if (!toolUseId || seen.has(toolUseId)) { + continue; + } + seen.add(toolUseId); + const toolCall = session.toolCalls.get(toolUseId); + if ( + !toolCall || + toolCall.status === "completed" || + toolCall.status === "failed" || + toolCall.status === "killed" + ) { + continue; + } + emitToolCallUpdate(session, toolUseId, { status: "failed" }, "finalize"); + reconciled += 1; + } + if (denials.length > 0) { + bridgeLogger.info({ + target: LOG_TARGETS.BRIDGE_PERMISSION, + eventName: "sdk_result_permission_denials_reconciled", + message: "SDK result permission denials reconciled with open tool calls", + outcome: "handled", + sessionId: session.sessionId, + count: reconciled, + fields: { denial_count: denials.length }, + }); } - emitUserMessageStartedForUuid(session, messageUuid, source); } function emitUserMessageStartedForUuid( @@ -1416,6 +1509,22 @@ export function handleResultMessage( message.fast_mode_state, message.fast_mode_disabled_reason, ); + reconcileResultPermissionDenials(session, message); + const replyDiagnostics = sdkMessageDiagnosticFields(message); + if ( + replyDiagnostics.resume_reason !== undefined || + replyDiagnostics.result_index !== undefined || + replyDiagnostics.local_command !== undefined + ) { + bridgeLogger.debug({ + target: LOG_TARGETS.BRIDGE_SDK, + eventName: "sdk_result_diagnostics", + message: "SDK result diagnostic metadata received", + outcome: "observed", + sessionId: session.sessionId, + fields: replyDiagnostics, + }); + } const terminalReason = terminalReasonFromValue(message.terminal_reason); const queuedTurnCount = nonNegativeIntegerField(message, "queued_turn_count"); @@ -1675,12 +1784,31 @@ export function handleSdkMessage( typeof msg.estimated_tokens_delta === "number" ? msg.estimated_tokens_delta : undefined, + user_message_uuid: trimmedStringField(msg, "user_message_uuid"), }, }); return; } if (subtype === "api_retry") { + const noResponse = asRecordOrNull(msg.no_response); + if (noResponse) { + bridgeLogger.debug({ + target: LOG_TARGETS.BRIDGE_SDK, + eventName: "sdk_api_retry_no_response", + message: "SDK API retry included no-response timing metadata", + outcome: "observed", + sessionId: session.sessionId, + fields: { + waited_ms: nonNegativeNumberField(noResponse, "waited_ms", "waitedMs"), + retry_wait_ms: nonNegativeNumberField( + noResponse, + "retry_wait_ms", + "retryWaitMs", + ), + }, + }); + } const update = buildApiRetryUpdate(msg); if (update) { emitSessionUpdate(session.sessionId, update); diff --git a/agent-sdk/src/bridge/permissions.ts b/agent-sdk/src/bridge/permissions.ts index 05147789..358ef5ce 100644 --- a/agent-sdk/src/bridge/permissions.ts +++ b/agent-sdk/src/bridge/permissions.ts @@ -74,20 +74,29 @@ function splitPermissionSuggestionsByScope( export function permissionOptionsFromSuggestions( suggestions: PermissionUpdate[] | undefined, + suppressAlwaysAllowRule = false, ): PermissionOption[] { const scoped = splitPermissionSuggestionsByScope(suggestions); const hasSessionScoped = scoped.session.length > 0; const hasPersistentScoped = scoped.persistent.length > 0; - const sessionOnly = hasSessionScoped && !hasPersistentScoped; const options: PermissionOption[] = [ { option_id: "allow_once", name: "Allow once", kind: "allow_once" }, ]; - options.push({ - option_id: sessionOnly ? "allow_session" : "allow_always", - name: sessionOnly ? "Allow for session" : "Always allow", - kind: sessionOnly ? "allow_session" : "allow_always", - }); + if (hasSessionScoped) { + options.push({ + option_id: "allow_session", + name: "Allow for session", + kind: "allow_session", + }); + } + if (!suppressAlwaysAllowRule && (!hasSessionScoped || hasPersistentScoped)) { + options.push({ + option_id: "allow_always", + name: "Always allow", + kind: "allow_always", + }); + } options.push({ option_id: "reject_once", name: "Deny", kind: "reject_once" }); return options; } diff --git a/agent-sdk/src/bridge/session_lifecycle.ts b/agent-sdk/src/bridge/session_lifecycle.ts index f98cf1d2..3fa59791 100644 --- a/agent-sdk/src/bridge/session_lifecycle.ts +++ b/agent-sdk/src/bridge/session_lifecycle.ts @@ -108,13 +108,25 @@ function permissionDisplayFromCanUseOptions( typeof options.displayName === "string" ? options.displayName.trim() : ""; const description = typeof options.description === "string" ? options.description.trim() : ""; - if (!title && !displayName && !description) { + const defaultToNo = options.defaultToNo === true; + const suppressAlwaysAllowRule = options.suppressAlwaysAllowRule === true; + if ( + !title && + !displayName && + !description && + !defaultToNo && + !suppressAlwaysAllowRule + ) { return undefined; } return { ...(title ? { title } : {}), ...(displayName ? { display_name: displayName } : {}), ...(description ? { description } : {}), + ...(defaultToNo ? { default_to_no: true } : {}), + ...(suppressAlwaysAllowRule + ? { suppress_always_allow_rule: true } + : {}), }; } @@ -569,7 +581,10 @@ export async function createSession(params: { const display = permissionDisplayFromCanUseOptions(options); const request: PermissionRequest = { tool_call: existing, - options: permissionOptionsFromSuggestions(options.suggestions), + options: permissionOptionsFromSuggestions( + options.suggestions, + options.suppressAlwaysAllowRule === true, + ), ...(display ? { display } : {}), }; bridgeLogger.info({ @@ -1161,8 +1176,6 @@ export function buildQueryOptions(params: QueryOptionsBuilderParams) { const permissionModeOptions = startupPermissionModeOptions( params.launchSettings, ); - const shouldPassCanUseTool = - permissionModeOptions.permissionMode !== "bypassPermissions"; const settings = normalizedSettingsFromLaunchSettings(params.launchSettings); return { cwd: params.cwd, @@ -1204,9 +1217,6 @@ export function buildQueryOptions(params: QueryOptionsBuilderParams) { }) => { const command = resolveClaudeCodeSpawnCommand(options.command); const env = { ...options.env }; - if (env.CLAUDE_CODE_ENABLE_TODO_TOOLS === undefined) { - env.CLAUDE_CODE_ENABLE_TODO_TOOLS = "1"; - } const spawnOptions = { ...options, command, env }; logSdkProcessSpawnStarted(spawnOptions, params.enableSpawnDebug); const child = spawnChild(command, options.args, { @@ -1251,7 +1261,7 @@ export function buildQueryOptions(params: QueryOptionsBuilderParams) { ...(params.forkSession ? { forkSession: true, sessionId: params.provisionalSessionId } : {}), - ...(shouldPassCanUseTool ? { canUseTool: params.canUseTool } : {}), + canUseTool: params.canUseTool, onElicitation: async ( request: { mode?: string; diff --git a/agent-sdk/src/bridge/state_parsing.ts b/agent-sdk/src/bridge/state_parsing.ts index a9702b8b..7b76c9dc 100644 --- a/agent-sdk/src/bridge/state_parsing.ts +++ b/agent-sdk/src/bridge/state_parsing.ts @@ -22,7 +22,7 @@ export function numberField( return undefined; } -function nonNegativeNumberField( +export function nonNegativeNumberField( record: Record, ...keys: string[] ): number | undefined { @@ -137,6 +137,8 @@ export function parseApiRetryError(value: unknown): ApiRetryError { case "invalid_request": case "model_not_found": case "server_error": + case "verification_required": + case "cloud_credential_error": case "max_output_tokens": return value; default: @@ -180,6 +182,14 @@ export function buildRateLimitUpdate( update.rate_limit_type = info.rateLimitType; } + if ( + info.limitScope === "service" || + info.limitScope === "channel" || + info.limitScope === "group_pool" + ) { + update.limit_scope = info.limitScope; + } + const overageStatus = parseRateLimitStatus(info.overageStatus); if (overageStatus) { update.overage_status = overageStatus; diff --git a/agent-sdk/src/bridge/tooling.test.ts b/agent-sdk/src/bridge/tooling.test.ts index ed0538c1..1ad0e115 100644 --- a/agent-sdk/src/bridge/tooling.test.ts +++ b/agent-sdk/src/bridge/tooling.test.ts @@ -676,6 +676,25 @@ test("buildToolResultFields maps structured Write output to diff content", () => ]); }); +test("buildToolResultFields preserves staged Write and Edit results", () => { + for (const [toolName, input, result] of [ + [ + "Write", + { file_path: "src/main.ts", content: "new" }, + { type: "update", filePath: "src/main.ts", content: "new", originalFile: "old", structuredPatch: [], staged: true }, + ], + [ + "Edit", + { file_path: "src/main.ts", old_string: "old", new_string: "new" }, + { filePath: "src/main.ts", oldString: "old", newString: "new", replaceAll: false, staged: true }, + ], + ] as const) { + const fields = buildToolResultFields(false, result, createToolCall(`tc-${toolName}`, toolName, input)); + assert.equal(fields.status, "completed"); + assert.equal(fields.output_metadata?.staged, true); + } +}); + test("buildToolResultFields distinguishes new, unchanged, and unavailable Write diffs", () => { const base = createToolCall("tc-write-cases", "Write", { file_path: "src/main.ts", diff --git a/agent-sdk/src/bridge/tooling.ts b/agent-sdk/src/bridge/tooling.ts index 7e726f97..8cdef098 100644 --- a/agent-sdk/src/bridge/tooling.ts +++ b/agent-sdk/src/bridge/tooling.ts @@ -711,6 +711,15 @@ function extractToolOutputMetadata( const candidates = collectResultCandidates(rawResult, rawContent); const metadata: import("../types.js").ToolOutputMetadata = {}; + if (toolName === "Edit" || toolName === "Write") { + for (const candidate of candidates) { + if (candidate.staged === true) { + metadata.staged = true; + break; + } + } + } + if (toolName === "Bash") { for (const candidate of candidates) { const hasAssistantAutoBackgrounded = @@ -790,7 +799,11 @@ function extractToolOutputMetadata( } } - return metadata.bash || metadata.agent || metadata.web_fetch || metadata.skill + return metadata.staged || + metadata.bash || + metadata.agent || + metadata.web_fetch || + metadata.skill ? metadata : undefined; } diff --git a/agent-sdk/src/types.ts b/agent-sdk/src/types.ts index 6f0320db..c95005e1 100644 --- a/agent-sdk/src/types.ts +++ b/agent-sdk/src/types.ts @@ -103,6 +103,7 @@ export interface RateLimitUpdate { resets_at?: number; utilization?: number; rate_limit_type?: string; + limit_scope?: "service" | "channel" | "group_pool"; overage_status?: RateLimitStatus; overage_resets_at?: number; overage_disabled_reason?: string; @@ -122,6 +123,8 @@ export type ApiRetryError = | "invalid_request" | "model_not_found" | "server_error" + | "verification_required" + | "cloud_credential_error" | "unknown" | "max_output_tokens"; @@ -209,6 +212,7 @@ export interface ToolNonExecutionMetadata { } export interface ToolOutputMetadata { + staged?: boolean; bash?: BashOutputMetadata; agent?: AgentOutputMetadata; web_fetch?: WebFetchOutputMetadata; @@ -435,6 +439,8 @@ export interface PermissionDisplay { title?: string; display_name?: string; description?: string; + default_to_no?: boolean; + suppress_always_allow_rule?: boolean; } export type ElicitationMode = "form" | "url"; @@ -897,6 +903,7 @@ export type BridgeCommand = | { command: "reload_plugins"; session_id: string; + force?: boolean; } | { command: "mcp_status"; @@ -965,6 +972,13 @@ export type TurnErrorKind = | "internal" | "other"; +export interface RuntimeReloadCacheImpact { + mcp_servers_added: string[]; + mcp_servers_removed: string[]; + lsp_tool_change?: "adds" | "may-add" | "removes" | "may-remove"; + invalid_server_name_count: number; +} + export type BridgeEvent = | { event: "connected"; @@ -1062,6 +1076,11 @@ export type BridgeEvent = message: string; } | { event: "runtime_reload_completed"; session_id: string } + | { + event: "runtime_reload_held"; + session_id: string; + cache_impact: RuntimeReloadCacheImpact; + } | { event: "runtime_reload_failed"; session_id: string; message: string } | { event: "session_replaced"; diff --git a/package-lock.json b/package-lock.json index 9b90200f..420245c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.14.6", "license": "Apache-2.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.258" + "@anthropic-ai/claude-agent-sdk": "0.3.270" }, "bin": { "claude-rs": "bin/claude-rs.js" @@ -22,22 +22,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.270.tgz", + "integrity": "sha512-sSfcm5Nhb+WHeBCxqeHRRQMUKPmFTL+zgv5xcRUVaFMLttfNEbn3IZJE+fLJJmy4h3J8zdc5sXdSa1JxyB8ppQ==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@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" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.270", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.270" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -46,9 +46,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.270.tgz", + "integrity": "sha512-nk7BP+i559rheYz9DIwAfevd4DulQXP0mXPP+MeO2fGuIGFmzhE/c0JRm9YswXv5HdaYJvSzjGIB7dVA01NehA==", "cpu": [ "arm64" ], @@ -59,9 +59,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.270.tgz", + "integrity": "sha512-89Uql8Oalm52ojdZZeNLU24LKrU+WG9QR7d6YP9ly4aY0YvQUJDaTosbDkijQngUETbPBFdKuVp6fNM8x0Zt3Q==", "cpu": [ "x64" ], @@ -72,9 +72,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.270.tgz", + "integrity": "sha512-iHPYqwetyeO4tZPzXyKZz0hUh2fLpwu/+biGTxxynikG3XrYknovZ/znGDA3TxjSFurqHf5IIDA+SOjh9OPs0A==", "cpu": [ "arm64" ], @@ -88,9 +88,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.270.tgz", + "integrity": "sha512-2BlLk2MAohWG2h43RKcjCA4ooMfBxzKf4yyYfOVv1DtYr8zPU876MHCT1VXB2BaemjKA0pdYJJBzH6pncwx6MQ==", "cpu": [ "arm64" ], @@ -104,9 +104,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.270.tgz", + "integrity": "sha512-ADaqz2viyAd0GUxdupYLX/K0YJb46xckNpEeWxyLK/9+26b/R5stbaGDyL29fIzyq1ymnNUOaCgEWEemgX0kEA==", "cpu": [ "x64" ], @@ -120,9 +120,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.270.tgz", + "integrity": "sha512-mzH3lnbzrbDGrTf75jLEmkbvkKRLLgmjLaWvf3QuUsgcw+aU69aOY0mW33oOrsuq5zg330uI3B4e68f4LbxNIA==", "cpu": [ "x64" ], @@ -136,9 +136,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.270.tgz", + "integrity": "sha512-Pexeu26cLZByhs6VlrawNYAEu+QE2YptvwNkXsmpLRm7Q/C/M0N/BZBmuUcikXrQQ9cuVzSjuuTTpZt64mvtLA==", "cpu": [ "arm64" ], @@ -149,9 +149,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "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==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.270.tgz", + "integrity": "sha512-9UyfFcUYsyUZqSe/xX9nIJ1Og6i8FxhlQ35BDi79Ik5He87XFxEIGUvJuMcl7Mq2e3panyhekELFE+9H79xKdw==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 5ab2b17f..57ea0654 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "README.md" ], "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.258" + "@anthropic-ai/claude-agent-sdk": "0.3.270" }, "scripts": { "prepack": "npm --prefix agent-sdk run build", diff --git a/src/agent/client.rs b/src/agent/client.rs index 3b7d7b39..1e0d3870 100644 --- a/src/agent/client.rs +++ b/src/agent/client.rs @@ -724,10 +724,10 @@ impl AgentConnection { }) } - pub fn reload_plugins(&self, session_id: String) -> anyhow::Result<()> { + pub fn reload_plugins(&self, session_id: String, force: bool) -> anyhow::Result<()> { self.send(CommandEnvelope { request_id: None, - command: BridgeCommand::ReloadPlugins { session_id }, + command: BridgeCommand::ReloadPlugins { session_id, force }, }) } @@ -1257,12 +1257,12 @@ mod tests { fn reload_plugins_sends_bridge_command() { let (conn, mut rx) = AgentConnection::test_channel(); - conn.reload_plugins("session-1".to_owned()).expect("reload plugins"); + conn.reload_plugins("session-1".to_owned(), false).expect("reload plugins"); let envelope = rx.try_recv().expect("command"); assert_eq!( envelope.command, - BridgeCommand::ReloadPlugins { session_id: "session-1".to_owned() } + BridgeCommand::ReloadPlugins { session_id: "session-1".to_owned(), force: false } ); } diff --git a/src/agent/error_handling.rs b/src/agent/error_handling.rs index 9b3df37b..da5d2a0f 100644 --- a/src/agent/error_handling.rs +++ b/src/agent/error_handling.rs @@ -90,6 +90,8 @@ fn looks_like_auth_required_error_lower(lower: &str) -> bool { "login required", "not authenticated", "unauthorized", + "verification_required", + "verification required", ] .iter() .any(|needle| lower.contains(needle)) @@ -102,6 +104,9 @@ fn looks_like_account_access_error_lower(lower: &str) -> bool { "organization not allowed", "account access not allowed", "org not allowed", + "cloud_credential_error", + "cloud credential error", + "cloud provider credentials", ] .iter() .any(|needle| lower.contains(needle)) diff --git a/src/agent/events.rs b/src/agent/events.rs index 37a9ac04..37ac2b18 100644 --- a/src/agent/events.rs +++ b/src/agent/events.rs @@ -117,6 +117,11 @@ pub enum ClientEvent { TerminalReturnedFromChild { reason: ReleaseReason }, /// Session runtime plugin reload completed successfully. RuntimeReloadCompleted { session_id: String }, + /// A safe plugin reload was held because it would invalidate the prompt cache. + RuntimeReloadHeld { + session_id: String, + cache_impact: crate::agent::types::RuntimeReloadCacheImpact, + }, /// Session runtime plugin reload failed after dispatch. RuntimeReloadFailed { session_id: String, message: String }, /// Custom slash command replaced the active session. @@ -214,6 +219,7 @@ impl ClientEvent { | Self::TurnError { session_id, .. } | Self::TurnErrorClassified { session_id, .. } | Self::RuntimeReloadCompleted { session_id } + | Self::RuntimeReloadHeld { session_id, .. } | Self::RuntimeReloadFailed { session_id, .. } | Self::StatusSnapshotReceived { session_id, .. } | Self::ContextUsageReceived { session_id, .. } diff --git a/src/agent/model/interactions.rs b/src/agent/model/interactions.rs index 61c86e6c..0d5aeece 100644 --- a/src/agent/model/interactions.rs +++ b/src/agent/model/interactions.rs @@ -213,6 +213,8 @@ pub struct PermissionDisplay { pub title: Option, pub display_name: Option, pub description: Option, + pub default_to_no: bool, + pub suppress_always_allow_rule: bool, } impl PermissionDisplay { @@ -239,11 +241,25 @@ impl PermissionDisplay { self } + #[must_use] + pub fn default_to_no(mut self, default_to_no: bool) -> Self { + self.default_to_no = default_to_no; + self + } + + #[must_use] + pub fn suppress_always_allow_rule(mut self, suppress: bool) -> Self { + self.suppress_always_allow_rule = suppress; + self + } + #[must_use] pub fn is_empty(&self) -> bool { self.title.as_ref().is_none_or(|value| value.trim().is_empty()) && self.display_name.as_ref().is_none_or(|value| value.trim().is_empty()) && self.description.as_ref().is_none_or(|value| value.trim().is_empty()) + && !self.default_to_no + && !self.suppress_always_allow_rule } } diff --git a/src/agent/model/session.rs b/src/agent/model/session.rs index 5ffb6d39..705f6e61 100644 --- a/src/agent/model/session.rs +++ b/src/agent/model/session.rs @@ -90,6 +90,8 @@ pub enum ApiRetryError { InvalidRequest, ModelNotFound, ServerError, + VerificationRequired, + CloudCredentialError, MaxOutputTokens, Unknown, } @@ -115,6 +117,7 @@ pub struct RateLimitUpdate { pub resets_at: Option, pub utilization: Option, pub rate_limit_type: Option, + pub limit_scope: Option, pub overage_status: Option, pub overage_resets_at: Option, pub overage_disabled_reason: Option, diff --git a/src/agent/model/tools.rs b/src/agent/model/tools.rs index 2b22487e..4457aece 100644 --- a/src/agent/model/tools.rs +++ b/src/agent/model/tools.rs @@ -357,6 +357,7 @@ impl BashOutputMetadata { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct ToolOutputMetadata { + pub staged: bool, pub bash: Option, pub agent: Option, pub web_fetch: Option, @@ -399,6 +400,12 @@ impl ToolOutputMetadata { Self::default() } + #[must_use] + pub fn staged(mut self, staged: bool) -> Self { + self.staged = staged; + self + } + #[must_use] pub fn bash(mut self, bash: Option) -> Self { self.bash = bash; diff --git a/src/agent/types.rs b/src/agent/types.rs index eb83da01..0b98e1b7 100644 --- a/src/agent/types.rs +++ b/src/agent/types.rs @@ -236,6 +236,8 @@ pub enum ApiRetryError { InvalidRequest, ModelNotFound, ServerError, + VerificationRequired, + CloudCredentialError, MaxOutputTokens, #[serde(other)] Unknown, @@ -283,6 +285,7 @@ pub struct RateLimitUpdate { pub resets_at: Option, pub utilization: Option, pub rate_limit_type: Option, + pub limit_scope: Option, pub overage_status: Option, pub overage_resets_at: Option, pub overage_disabled_reason: Option, @@ -292,6 +295,17 @@ pub struct RateLimitUpdate { pub has_chargeable_saved_payment_method: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeReloadCacheImpact { + #[serde(default)] + pub mcp_servers_added: Vec, + #[serde(default)] + pub mcp_servers_removed: Vec, + pub lsp_tool_change: Option, + #[serde(default)] + pub invalid_server_name_count: usize, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SessionStatus { @@ -422,6 +436,8 @@ pub struct BashOutputMetadata { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct ToolOutputMetadata { + #[serde(default)] + pub staged: bool, pub bash: Option, pub agent: Option, pub web_fetch: Option, @@ -635,6 +651,7 @@ pub enum SessionUpdate { resets_at: Option, utilization: Option, rate_limit_type: Option, + limit_scope: Option, overage_status: Option, overage_resets_at: Option, overage_disabled_reason: Option, @@ -708,6 +725,10 @@ pub struct PermissionDisplay { pub title: Option, pub display_name: Option, pub description: Option, + #[serde(default)] + pub default_to_no: bool, + #[serde(default)] + pub suppress_always_allow_rule: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/agent/wire.rs b/src/agent/wire.rs index 001075a3..dfcbedcf 100644 --- a/src/agent/wire.rs +++ b/src/agent/wire.rs @@ -145,6 +145,8 @@ pub enum BridgeCommand { }, ReloadPlugins { session_id: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + force: bool, }, GetMcpSnapshot { session_id: String, @@ -241,7 +243,7 @@ impl BridgeCommand { | Self::GetUsage { session_id } | Self::GetRewindTargets { session_id } | Self::Rewind { session_id, .. } - | Self::ReloadPlugins { session_id } + | Self::ReloadPlugins { session_id, .. } | Self::GetMcpSnapshot { session_id } | Self::McpReconnect { session_id, .. } | Self::McpToggle { session_id, .. } @@ -408,6 +410,10 @@ pub enum BridgeEvent { RuntimeReloadCompleted { session_id: String, }, + RuntimeReloadHeld { + session_id: String, + cache_impact: types::RuntimeReloadCacheImpact, + }, RuntimeReloadFailed { session_id: String, message: String, @@ -493,6 +499,7 @@ impl BridgeEvent { Self::SlashError { .. } => "slash_error", Self::SessionResumeFailed { .. } => "session_resume_failed", Self::RuntimeReloadCompleted { .. } => "runtime_reload_completed", + Self::RuntimeReloadHeld { .. } => "runtime_reload_held", Self::RuntimeReloadFailed { .. } => "runtime_reload_failed", Self::SessionReplaced { .. } => "session_replaced", Self::Initialized { .. } => "initialized", @@ -528,6 +535,7 @@ impl BridgeEvent { | Self::SlashError { session_id, .. } | Self::SessionResumeFailed { session_id, .. } | Self::RuntimeReloadCompleted { session_id, .. } + | Self::RuntimeReloadHeld { session_id, .. } | Self::RuntimeReloadFailed { session_id, .. } | Self::SessionReplaced { session_id, .. } | Self::StatusSnapshot { session_id, .. } @@ -569,6 +577,7 @@ impl BridgeEvent { | Self::SlashError { .. } | Self::SessionResumeFailed { .. } | Self::RuntimeReloadCompleted { .. } + | Self::RuntimeReloadHeld { .. } | Self::RuntimeReloadFailed { .. } | Self::SessionReplaced { .. } | Self::Initialized { .. } diff --git a/src/app/config/edit.rs b/src/app/config/edit.rs index 182c0552..6f38f996 100644 --- a/src/app/config/edit.rs +++ b/src/app/config/edit.rs @@ -239,7 +239,18 @@ fn restore_confirmation_previous_overlay(app: &mut App) { let Some(overlay) = app.config.confirmation_overlay().cloned() else { return; }; - app.config.replace_overlay(*overlay.previous); + restore_previous_overlay(app, overlay.previous); + if overlay.action == ConfirmationAction::ForceRuntimePluginReload { + crate::app::plugins::cancel_held_runtime_reload(app); + } +} + +fn restore_previous_overlay(app: &mut App, previous: Option>) { + if let Some(previous) = previous { + app.config.replace_overlay(*previous); + } else { + app.config.clear_overlay(); + } } fn confirm_confirmation_overlay(app: &mut App) { @@ -247,12 +258,12 @@ fn confirm_confirmation_overlay(app: &mut App) { return; }; if overlay.selected_index == 0 { - app.config.replace_overlay(*overlay.previous); + restore_confirmation_previous_overlay(app); return; } let action = overlay.action; - app.config.replace_overlay(*overlay.previous); + restore_previous_overlay(app, overlay.previous); match action { ConfirmationAction::InstalledPluginUninstall => { crate::app::plugins::execute_confirmed_installed_plugin_action( @@ -302,6 +313,9 @@ fn confirm_confirmation_overlay(app: &mut App) { }; super::mcp_edit::execute_confirmed_mcp_server_action(app, action); } + ConfirmationAction::ForceRuntimePluginReload => { + crate::app::plugins::force_held_runtime_reload(app); + } } } diff --git a/src/app/config/mcp_edit.rs b/src/app/config/mcp_edit.rs index a1846cd1..839c43c2 100644 --- a/src/app/config/mcp_edit.rs +++ b/src/app/config/mcp_edit.rs @@ -168,7 +168,7 @@ fn open_mcp_clear_auth_confirmation(app: &mut App, overlay: super::mcp::McpDetai cancel_label: "Cancel".to_owned(), selected_index: 0, action: ConfirmationAction::McpClearAuth, - previous: Box::new(ConfigOverlayState::McpDetails(overlay)), + previous: Some(Box::new(ConfigOverlayState::McpDetails(overlay))), })); } @@ -197,7 +197,7 @@ fn open_mcp_remove_confirmation( cancel_label: "Cancel".to_owned(), selected_index: 0, action: ConfirmationAction::McpRemoveConfig, - previous: Box::new(ConfigOverlayState::McpDetails(overlay)), + previous: Some(Box::new(ConfigOverlayState::McpDetails(overlay))), })); } diff --git a/src/app/config/overlays.rs b/src/app/config/overlays.rs index a387bac3..50e651f7 100644 --- a/src/app/config/overlays.rs +++ b/src/app/config/overlays.rs @@ -162,6 +162,7 @@ pub enum ConfirmationAction { MarketplaceRemove, McpClearAuth, McpRemoveConfig, + ForceRuntimePluginReload, } #[derive(Debug, Clone, PartialEq)] @@ -172,7 +173,7 @@ pub struct ConfirmationOverlayState { pub cancel_label: String, pub selected_index: usize, pub action: ConfirmationAction, - pub previous: Box, + pub previous: Option>, } #[derive(Debug, Clone, PartialEq)] diff --git a/src/app/config/tests.rs b/src/app/config/tests.rs index d32b2760..c7666d32 100644 --- a/src/app/config/tests.rs +++ b/src/app/config/tests.rs @@ -1965,7 +1965,7 @@ fn mcp_config_remove_success_reloads_runtime_without_extra_snapshot() { let envelope = rx.try_recv().expect("runtime reload command"); assert_eq!( envelope.command, - BridgeCommand::ReloadPlugins { session_id: "session-1".to_owned() } + BridgeCommand::ReloadPlugins { session_id: "session-1".to_owned(), force: false } ); assert!(app.mcp.in_flight); assert!(rx.try_recv().is_err()); @@ -2430,7 +2430,7 @@ fn mcp_tab_refresh_key_requests_snapshot() { let envelope = rx.try_recv().expect("runtime reload command"); assert_eq!( envelope.command, - BridgeCommand::ReloadPlugins { session_id: "session-1".to_owned() } + BridgeCommand::ReloadPlugins { session_id: "session-1".to_owned(), force: false } ); let envelope = rx.try_recv().expect("mcp snapshot command"); assert_eq!( diff --git a/src/app/connect/event_dispatch.rs b/src/app/connect/event_dispatch.rs index db53a33c..7776014e 100644 --- a/src/app/connect/event_dispatch.rs +++ b/src/app/connect/event_dispatch.rs @@ -211,6 +211,10 @@ pub(super) async fn handle_bridge_event( crate::agent::wire::BridgeEvent::RuntimeReloadCompleted { session_id } => { let _ = event_tx.send(ClientEvent::RuntimeReloadCompleted { session_id }).await; } + crate::agent::wire::BridgeEvent::RuntimeReloadHeld { session_id, cache_impact } => { + let _ = + event_tx.send(ClientEvent::RuntimeReloadHeld { session_id, cache_impact }).await; + } crate::agent::wire::BridgeEvent::RuntimeReloadFailed { session_id, message } => { let _ = event_tx.send(ClientEvent::RuntimeReloadFailed { session_id, message }).await; } diff --git a/src/app/connect/type_converters.rs b/src/app/connect/type_converters.rs index e73c4252..47f955d0 100644 --- a/src/app/connect/type_converters.rs +++ b/src/app/connect/type_converters.rs @@ -22,6 +22,7 @@ pub(super) fn map_rate_limit_update(update: types::RateLimitUpdate) -> model::Ra resets_at: update.resets_at, utilization: update.utilization, rate_limit_type: update.rate_limit_type, + limit_scope: update.limit_scope, overage_status: update.overage_status.map(map_rate_limit_status), overage_resets_at: update.overage_resets_at, overage_disabled_reason: update.overage_disabled_reason, @@ -43,6 +44,8 @@ pub(super) fn map_api_retry_error(error: types::ApiRetryError) -> model::ApiRetr types::ApiRetryError::InvalidRequest => model::ApiRetryError::InvalidRequest, types::ApiRetryError::ModelNotFound => model::ApiRetryError::ModelNotFound, types::ApiRetryError::ServerError => model::ApiRetryError::ServerError, + types::ApiRetryError::VerificationRequired => model::ApiRetryError::VerificationRequired, + types::ApiRetryError::CloudCredentialError => model::ApiRetryError::CloudCredentialError, types::ApiRetryError::MaxOutputTokens => model::ApiRetryError::MaxOutputTokens, types::ApiRetryError::Unknown => model::ApiRetryError::Unknown, } @@ -461,6 +464,7 @@ pub(super) fn map_session_update(update: types::SessionUpdate) -> Option Option model::ToolOutputMetadata { model::ToolOutputMetadata::new() + .staged(output_metadata.staged) .bash(output_metadata.bash.map(|bash| { model::BashOutputMetadata::new() .assistant_auto_backgrounded(bash.assistant_auto_backgrounded) @@ -931,7 +937,9 @@ fn convert_permission_display( let mapped = model::PermissionDisplay::new() .title(display.title) .display_name(display.display_name) - .description(display.description); + .description(display.description) + .default_to_no(display.default_to_no) + .suppress_always_allow_rule(display.suppress_always_allow_rule); (!mapped.is_empty()).then_some(mapped) } @@ -1421,6 +1429,7 @@ mod tests { resets_at: None, utilization: None, rate_limit_type: None, + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: None, @@ -1496,6 +1505,8 @@ mod tests { title: Some("Claude wants to run tests".to_owned()), display_name: Some("Run tests".to_owned()), description: Some("This command reads project files".to_owned()), + default_to_no: false, + suppress_always_allow_rule: false, }), }, ); @@ -1597,6 +1608,7 @@ mod tests { let fields = convert_tool_call_update_fields(types::ToolCallUpdateFields { status: Some("completed".to_owned()), output_metadata: Some(types::ToolOutputMetadata { + staged: false, bash: Some(types::BashOutputMetadata { assistant_auto_backgrounded: Some(true), timed_out_after_ms: Some(10_000), diff --git a/src/app/events/api_retry.rs b/src/app/events/api_retry.rs index a5d2d31c..a03e7d4f 100644 --- a/src/app/events/api_retry.rs +++ b/src/app/events/api_retry.rs @@ -48,6 +48,8 @@ fn api_retry_error_label(error: ApiRetryError) -> &'static str { ApiRetryError::InvalidRequest => "invalid request", ApiRetryError::ModelNotFound => "model unavailable", ApiRetryError::ServerError => "server error", + ApiRetryError::VerificationRequired => "verification required", + ApiRetryError::CloudCredentialError => "cloud provider credentials", ApiRetryError::MaxOutputTokens => "max output tokens", ApiRetryError::Unknown => "connection error", } diff --git a/src/app/events/client.rs b/src/app/events/client.rs index 44f60a60..8582a1f0 100644 --- a/src/app/events/client.rs +++ b/src/app/events/client.rs @@ -96,6 +96,7 @@ fn client_event_family(event: &ClientEvent) -> ClientEventFamily { ClientEvent::TerminalReleasedToChild { .. } | ClientEvent::TerminalReturnedFromChild { .. } | ClientEvent::RuntimeReloadCompleted { .. } + | ClientEvent::RuntimeReloadHeld { .. } | ClientEvent::RuntimeReloadFailed { .. } | ClientEvent::StructuredUsageReceived { .. } | ClientEvent::UsageRefreshStarted { .. } diff --git a/src/app/events/client/host.rs b/src/app/events/client/host.rs index 414a8a71..8a8a1ef9 100644 --- a/src/app/events/client/host.rs +++ b/src/app/events/client/host.rs @@ -20,6 +20,12 @@ pub(super) fn handle(app: &mut App, event: ClientEvent) { ClientEvent::RuntimeReloadCompleted { session_id: _ } => { crate::app::plugins::apply_runtime_reload_success(app); } + ClientEvent::RuntimeReloadHeld { session_id: _, cache_impact } => { + crate::app::plugins::apply_runtime_reload_held(app, &cache_impact); + if app.mcp.in_flight { + app.mcp.in_flight = false; + } + } ClientEvent::RuntimeReloadFailed { session_id: _, message } => { crate::app::plugins::apply_runtime_reload_failure(app, &message); if app.mcp.in_flight { diff --git a/src/app/events/rate_limit.rs b/src/app/events/rate_limit.rs index 16c2a614..0c116c7c 100644 --- a/src/app/events/rate_limit.rs +++ b/src/app/events/rate_limit.rs @@ -68,7 +68,13 @@ pub(super) fn format_rate_limit_summary(update: &model::RateLimitUpdate) -> Stri let is_rejected = matches!(update.status, model::RateLimitStatus::Rejected); // Intro - let intro = if is_rejected { "Rate limit reached" } else { "Approaching rate limit" }; + let shared_group_pool = update.limit_scope.as_deref() == Some("group_pool"); + let intro = match (is_rejected, shared_group_pool) { + (true, true) => "Shared group rate limit reached", + (false, true) => "Approaching shared group rate limit", + (true, false) => "Rate limit reached", + (false, false) => "Approaching rate limit", + }; // "you've used 91% of your 5-hour rate limit" let usage_part = match (update.utilization, &update.rate_limit_type) { @@ -113,6 +119,7 @@ pub(super) fn format_rate_limit_summary(update: &model::RateLimitUpdate) -> Stri pub(super) fn rate_limit_notice_key(update: &model::RateLimitUpdate) -> NoticeDedupKey { NoticeDedupKey::RateLimit(RateLimitIncidentKey { rate_limit_type: update.rate_limit_type.clone(), + limit_scope: update.limit_scope.clone(), resets_at_bucket: update.resets_at.and_then(reset_bucket_from_epoch_secs), }) } @@ -134,6 +141,7 @@ pub(super) fn handle_rate_limit_update(app: &mut App, update: &model::RateLimitU status = ?update.status, utilization = update.utilization, rate_limit_type = update.rate_limit_type.as_deref().unwrap_or(""), + limit_scope = update.limit_scope.as_deref().unwrap_or(""), resets_at = update.resets_at.unwrap_or_default(), overage_status = ?update.overage_status, overage_resets_at = update.overage_resets_at.unwrap_or_default(), @@ -180,6 +188,7 @@ mod tests { resets_at: None, utilization: None, rate_limit_type: None, + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: Some("org_level_disabled".to_owned()), @@ -203,6 +212,7 @@ mod tests { resets_at: Some(1_741_280_000.0), utilization: None, rate_limit_type: Some("five_hour".to_owned()), + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: Some("org_level_disabled".to_owned()), @@ -226,6 +236,7 @@ mod tests { resets_at: None, utilization: Some(0.92), rate_limit_type: Some("seven_day_overage_included".to_owned()), + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: None, diff --git a/src/app/events/tests.rs b/src/app/events/tests.rs index 81bc6aa4..1802f46d 100644 --- a/src/app/events/tests.rs +++ b/src/app/events/tests.rs @@ -2238,6 +2238,7 @@ fn rate_limit_notices_dedup_and_upgrade_in_place() { resets_at: Some(123.0), utilization: Some(0.92), rate_limit_type: Some("five_hour".to_owned()), + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: None, @@ -2300,6 +2301,7 @@ fn plan_limit_turn_error_upgrades_inline_notice_in_active_assistant() { resets_at: Some(1_741_280_000.0), utilization: Some(0.95), rate_limit_type: Some("five_hour".to_owned()), + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: None, @@ -2347,6 +2349,7 @@ fn different_rate_limit_incident_in_later_turn_keeps_older_notice() { resets_at: Some(1_741_280_000.0), utilization: Some(0.95), rate_limit_type: Some("five_hour".to_owned()), + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: None, @@ -2390,6 +2393,7 @@ fn different_rate_limit_incident_in_later_turn_keeps_older_notice() { resets_at: Some(1_741_290_000.0), utilization: None, rate_limit_type: Some("daily".to_owned()), + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: None, @@ -2429,6 +2433,7 @@ fn turn_notice_tracking_clears_on_turn_complete_and_session_reset() { resets_at: Some(123.0), utilization: Some(0.91), rate_limit_type: Some("five_hour".to_owned()), + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: None, @@ -2455,6 +2460,7 @@ fn turn_notice_tracking_clears_on_turn_complete_and_session_reset() { resets_at: Some(456.0), utilization: Some(0.92), rate_limit_type: Some("daily".to_owned()), + limit_scope: None, overage_status: None, overage_resets_at: None, overage_disabled_reason: None, diff --git a/src/app/events/turn.rs b/src/app/events/turn.rs index 008c0590..197abf5b 100644 --- a/src/app/events/turn.rs +++ b/src/app/events/turn.rs @@ -22,6 +22,24 @@ const PLAN_LIMIT_NEXT_STEPS_HINT: &str = "Next steps:\n\ 3. Check quota/billing for your account or switch plans."; const AUTH_REQUIRED_NEXT_STEPS_HINT: &str = "Authentication required. Type /login to authenticate, or run `claude auth login` in a terminal."; +fn permission_initial_selected_index(request: &model::RequestPermissionRequest) -> usize { + if !request.display.as_ref().is_some_and(|display| display.default_to_no) { + return 0; + } + request + .options + .iter() + .position(|option| { + matches!( + option.kind, + model::PermissionOptionKind::RejectOnce + | model::PermissionOptionKind::RejectAlways + | model::PermissionOptionKind::PlanReject + ) + }) + .unwrap_or_else(|| request.options.len().saturating_sub(1)) +} + #[derive(Clone, Copy)] struct TurnExitState { tail_assistant_idx: Option, @@ -46,6 +64,7 @@ pub(super) fn handle_permission_request_event( let display_description = permission_display_field(request.display.as_ref(), |display| { display.description.as_deref() }); + let selected_index = permission_initial_selected_index(&request); let Some((mi, bi)) = app.lookup_tool_call(&tool_id) else { tracing::warn!( @@ -87,7 +106,7 @@ pub(super) fn handle_permission_request_event( display: request.display, subagent_context: permission_context.clone(), response_tx, - selected_index: 0, + selected_index, focused: auto_focus, }); tc.invalidate_render_cache(); @@ -835,6 +854,7 @@ fn push_turn_error_message( base_message, super::super::NoticeDedupKey::RateLimit(super::super::RateLimitIncidentKey { rate_limit_type: None, + limit_scope: None, resets_at_bucket: None, }), ) @@ -1323,6 +1343,40 @@ mod tests { assert!(tool.pending_permission.is_some()); } + #[test] + fn permission_request_defaults_to_denial_when_sdk_requests_it() { + let mut app = App::test_default(); + push_tool(&mut app, tool_call_info("bash-1", "Bash", "Bash", None, false)); + let (tx, _rx) = tokio::sync::oneshot::channel(); + let request = model::RequestPermissionRequest::new( + "session-1", + model::ToolCallUpdate::new("bash-1", model::ToolCallUpdateFields::new()), + vec![ + model::PermissionOption::new( + "allow", + "Allow", + model::PermissionOptionKind::AllowOnce, + ), + model::PermissionOption::new( + "deny", + "Deny", + model::PermissionOptionKind::RejectOnce, + ), + ], + Some(model::PermissionDisplay::new().default_to_no(true)), + ); + + handle_permission_request_event(&mut app, request, tx); + + let Some(MessageBlock::ToolCall(tool)) = app.transcript.messages[0].blocks.first() else { + panic!("expected tool call block"); + }; + assert_eq!( + tool.pending_permission.as_ref().map(|permission| permission.selected_index), + Some(1) + ); + } + #[test] fn permission_request_applied_log_includes_subagent_and_display_fields() { let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); diff --git a/src/app/plugins/actions.rs b/src/app/plugins/actions.rs index 7ca432f7..7db88ab6 100644 --- a/src/app/plugins/actions.rs +++ b/src/app/plugins/actions.rs @@ -247,6 +247,76 @@ pub(crate) fn apply_runtime_reload_failure(app: &mut App, message: &str) { app.config.last_error = Some(format!("Failed to reload session plugins: {message}")); } +pub(crate) fn apply_runtime_reload_held( + app: &mut App, + impact: &crate::agent::types::RuntimeReloadCacheImpact, +) { + app.plugins.loading = false; + app.plugins.last_error = None; + let mut changes = Vec::new(); + changes.extend(impact.mcp_servers_added.iter().map(|name| format!("Add MCP server: {name}"))); + changes + .extend(impact.mcp_servers_removed.iter().map(|name| format!("Remove MCP server: {name}"))); + if let Some(change) = impact.lsp_tool_change.as_deref() { + changes.push(format!("LSP tool change: {change}")); + } + if impact.invalid_server_name_count > 0 { + changes.push(format!( + "{} plugin-authored server name(s) were hidden because they were unsafe to display", + impact.invalid_server_name_count + )); + } + if changes.is_empty() { + changes.push("The session tool list would change".to_owned()); + } + let status = + "Plugin files were updated, but the session reload was held to preserve the prompt cache."; + app.plugins.status_message = Some(status.to_owned()); + app.config.status_message = Some(status.to_owned()); + app.config.last_error = None; + let previous = app.config.overlay.take().map(Box::new); + app.config.replace_overlay(ConfigOverlayState::Confirmation(ConfirmationOverlayState { + title: "Reload plugins and invalidate prompt cache?".to_owned(), + body: format!( + "Applying the plugin reload will change the session tool list and invalidate the current prompt cache:\n\n{}", + changes.join("\n") + ), + confirm_label: "Force reload".to_owned(), + cancel_label: "Keep current session tools".to_owned(), + selected_index: 0, + action: ConfirmationAction::ForceRuntimePluginReload, + previous, + })); +} + +pub(crate) fn cancel_held_runtime_reload(app: &mut App) { + app.plugins.loading = false; + app.plugins.pending_runtime_reload_success_message = None; + let status = "Plugin files were updated; the current session kept its existing tools to preserve the prompt cache."; + app.plugins.status_message = Some(status.to_owned()); + app.config.status_message = Some(status.to_owned()); +} + +pub(crate) fn force_held_runtime_reload(app: &mut App) { + app.plugins.loading = true; + app.plugins.status_message = + Some("Reloading session plugins and rebuilding prompt cache...".to_owned()); + app.config.status_message = + Some("Reloading session plugins and rebuilding prompt cache...".to_owned()); + match crate::app::session_runtime::request_forced_runtime_reload(app) { + crate::app::session_runtime::RuntimeReloadRequestOutcome::Requested => {} + crate::app::session_runtime::RuntimeReloadRequestOutcome::Unavailable => { + apply_runtime_reload_success(app); + } + crate::app::session_runtime::RuntimeReloadRequestOutcome::Failed => { + apply_runtime_reload_failure( + app, + "failed to request forced session runtime plugin reload", + ); + } + } +} + pub(super) fn start_runtime_reload(app: &mut App, success_message: String) { app.plugins.loading = true; app.plugins.status_message = Some("Reloading session plugins...".to_owned()); diff --git a/src/app/plugins/mod.rs b/src/app/plugins/mod.rs index 5df1fb30..1180e1c1 100644 --- a/src/app/plugins/mod.rs +++ b/src/app/plugins/mod.rs @@ -13,8 +13,9 @@ mod types; pub(crate) use actions::{ apply_cli_action_failure, apply_cli_action_success, apply_runtime_reload_failure, - apply_runtime_reload_success, execute_confirmed_installed_plugin_action, - execute_confirmed_marketplace_action, + apply_runtime_reload_held, apply_runtime_reload_success, cancel_held_runtime_reload, + execute_confirmed_installed_plugin_action, execute_confirmed_marketplace_action, + force_held_runtime_reload, }; pub(crate) use inventory::{ apply_inventory_refresh_failure, apply_inventory_refresh_success, diff --git a/src/app/plugins/overlays.rs b/src/app/plugins/overlays.rs index 2c544c00..ba63532e 100644 --- a/src/app/plugins/overlays.rs +++ b/src/app/plugins/overlays.rs @@ -258,7 +258,7 @@ pub(super) fn open_confirmation_overlay( cancel_label: "Cancel".to_owned(), selected_index: 0, action, - previous: Box::new(previous), + previous: Some(Box::new(previous)), })); } diff --git a/src/app/plugins/tests.rs b/src/app/plugins/tests.rs index bc1d69b3..0af6cce1 100644 --- a/src/app/plugins/tests.rs +++ b/src/app/plugins/tests.rs @@ -165,7 +165,7 @@ fn inventory_refresh_success_triggers_runtime_reload_when_requested() { let envelope = rx.try_recv().expect("reload command"); assert!(matches!( envelope.command, - BridgeCommand::ReloadPlugins { session_id } if session_id == "session-1" + BridgeCommand::ReloadPlugins { session_id, force: false } if session_id == "session-1" )); assert!(!app.plugins.runtime_reload_after_refresh); assert_eq!(app.config.status_message.as_deref(), Some("Reloading session plugins...")); @@ -191,7 +191,7 @@ fn cli_action_success_triggers_runtime_reload() { let envelope = rx.try_recv().expect("reload command"); assert!(matches!( envelope.command, - BridgeCommand::ReloadPlugins { session_id } if session_id == "session-1" + BridgeCommand::ReloadPlugins { session_id, force: false } if session_id == "session-1" )); assert_eq!( app.plugins.pending_runtime_reload_success_message.as_deref(), @@ -241,7 +241,7 @@ fn cli_action_success_reconciles_stale_plugin_mcp_servers() { let envelope = rx.try_recv().expect("reload command"); assert!(matches!( envelope.command, - BridgeCommand::ReloadPlugins { session_id } if session_id == "session-1" + BridgeCommand::ReloadPlugins { session_id, force: false } if session_id == "session-1" )); assert_eq!( app.mcp.servers.iter().map(|server| server.name.as_str()).collect::>(), @@ -278,6 +278,60 @@ fn runtime_reload_failure_surfaces_visible_error() { assert!(app.plugins.pending_runtime_reload_success_message.is_none()); } +#[test] +fn held_runtime_reload_requires_explicit_force_and_preserves_pending_success() { + let (mut app, mut rx) = app_with_connection(); + app.plugins.loading = true; + app.plugins.pending_runtime_reload_success_message = Some("Updated plugin".to_owned()); + + apply_runtime_reload_held( + &mut app, + &crate::agent::types::RuntimeReloadCacheImpact { + mcp_servers_added: vec!["plugin:docs:search".to_owned()], + mcp_servers_removed: Vec::new(), + lsp_tool_change: Some("adds".to_owned()), + invalid_server_name_count: 1, + }, + ); + + assert!(!app.plugins.loading); + assert!(matches!( + app.config.overlay, + Some(crate::app::config::ConfigOverlayState::Confirmation(ref overlay)) + if overlay.action == crate::app::config::ConfirmationAction::ForceRuntimePluginReload + && overlay.selected_index == 0 + && overlay.body.contains("plugin:docs:search") + && overlay.body.contains("unsafe to display") + )); + assert_eq!( + app.plugins.pending_runtime_reload_success_message.as_deref(), + Some("Updated plugin") + ); + + force_held_runtime_reload(&mut app); + let envelope = rx.try_recv().expect("forced reload command"); + assert!(matches!( + envelope.command, + BridgeCommand::ReloadPlugins { session_id, force: true } if session_id == "session-1" + )); +} + +#[test] +fn cancelling_held_runtime_reload_does_not_report_runtime_success() { + let mut app = App::test_default(); + app.plugins.pending_runtime_reload_success_message = Some("Updated plugin".to_owned()); + + cancel_held_runtime_reload(&mut app); + + assert!(app.plugins.pending_runtime_reload_success_message.is_none()); + assert!( + app.config + .status_message + .as_deref() + .is_some_and(|message| message.contains("kept its existing tools")) + ); +} + #[test] fn cli_action_success_without_active_session_keeps_success_message() { let mut app = App::test_default(); diff --git a/src/app/session_runtime.rs b/src/app/session_runtime.rs index 43f687fe..7290a8df 100644 --- a/src/app/session_runtime.rs +++ b/src/app/session_runtime.rs @@ -13,6 +13,14 @@ pub(crate) enum RuntimeReloadRequestOutcome { } pub(crate) fn request_runtime_reload(app: &mut App) -> RuntimeReloadRequestOutcome { + request_runtime_reload_with_force(app, false) +} + +pub(crate) fn request_forced_runtime_reload(app: &mut App) -> RuntimeReloadRequestOutcome { + request_runtime_reload_with_force(app, true) +} + +fn request_runtime_reload_with_force(app: &mut App, force: bool) -> RuntimeReloadRequestOutcome { let Some(conn) = app.session_runtime.conn.as_ref() else { return RuntimeReloadRequestOutcome::Unavailable; }; @@ -20,7 +28,7 @@ pub(crate) fn request_runtime_reload(app: &mut App) -> RuntimeReloadRequestOutco return RuntimeReloadRequestOutcome::Unavailable; }; let session_id = sid.to_string(); - match conn.reload_plugins(session_id.clone()) { + match conn.reload_plugins(session_id.clone(), force) { Ok(()) => { tracing::debug!( target: crate::logging::targets::APP_SESSION, @@ -28,6 +36,7 @@ pub(crate) fn request_runtime_reload(app: &mut App) -> RuntimeReloadRequestOutco message = "session runtime plugin reload requested", outcome = "start", session_id = %session_id, + force, ); RuntimeReloadRequestOutcome::Requested } @@ -38,6 +47,7 @@ pub(crate) fn request_runtime_reload(app: &mut App) -> RuntimeReloadRequestOutco message = "failed to request session runtime plugin reload", outcome = "failure", session_id = %session_id, + force, error_message = %error, ); RuntimeReloadRequestOutcome::Failed @@ -165,8 +175,9 @@ fn clear_context_usage_refresh_state(app: &mut App) { mod tests { use super::{ CONTEXT_USAGE_REFRESH_INTERVAL, RuntimeReloadRequestOutcome, apply_context_usage_snapshot, - request_context_usage_refresh, request_context_usage_refresh_at, request_runtime_reload, - request_status_snapshot_refresh, tick_context_usage_refresh, + request_context_usage_refresh, request_context_usage_refresh_at, + request_forced_runtime_reload, request_runtime_reload, request_status_snapshot_refresh, + tick_context_usage_refresh, }; use crate::agent::model; use crate::agent::wire::BridgeCommand; @@ -204,7 +215,7 @@ mod tests { let envelope = rx.try_recv().expect("reload command"); assert!(matches!( envelope.command, - BridgeCommand::ReloadPlugins { session_id } if session_id == "session-1" + BridgeCommand::ReloadPlugins { session_id, force: false } if session_id == "session-1" )); } @@ -218,6 +229,22 @@ mod tests { )); } + #[test] + fn forced_runtime_reload_sets_explicit_command_flag() { + let (mut app, mut rx) = app_with_connection(); + + assert!(matches!( + request_forced_runtime_reload(&mut app), + RuntimeReloadRequestOutcome::Requested + )); + + let envelope = rx.try_recv().expect("forced reload command"); + assert!(matches!( + envelope.command, + BridgeCommand::ReloadPlugins { session_id, force: true } if session_id == "session-1" + )); + } + #[test] fn request_context_usage_refresh_coalesces_in_flight_requests() { let (mut app, mut rx) = app_with_connection(); diff --git a/src/app/state/messages.rs b/src/app/state/messages.rs index 927b0805..d4947f79 100644 --- a/src/app/state/messages.rs +++ b/src/app/state/messages.rs @@ -394,6 +394,7 @@ impl TextBlock { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RateLimitIncidentKey { pub rate_limit_type: Option, + pub limit_scope: Option, pub resets_at_bucket: Option, } diff --git a/src/app/state/tool_call_info.rs b/src/app/state/tool_call_info.rs index 02b93a6a..34ed8c03 100644 --- a/src/app/state/tool_call_info.rs +++ b/src/app/state/tool_call_info.rs @@ -135,6 +135,11 @@ impl ToolCallInfo { self.output_metadata.as_ref()?.non_execution.as_ref() } + #[must_use] + pub fn output_was_staged(&self) -> bool { + self.output_metadata.as_ref().is_some_and(|metadata| metadata.staged) + } + #[must_use] pub fn hidden_unless_focused_interaction(&self) -> bool { self.hidden && self.pending_permission.is_none() && self.pending_question.is_none() diff --git a/src/ui/config.rs b/src/ui/config.rs index 7cfcc41d..d4753fc2 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -763,9 +763,9 @@ mod tests { cancel_label: "Cancel".to_owned(), selected_index: 0, action: crate::app::config::ConfirmationAction::MarketplaceRemove, - previous: Box::new(ConfigOverlayState::OutputStyle(OutputStyleOverlayState { - selected: OutputStyle::Default, - })), + previous: Some(Box::new(ConfigOverlayState::OutputStyle( + OutputStyleOverlayState { selected: OutputStyle::Default }, + ))), }, )); @@ -788,9 +788,9 @@ mod tests { cancel_label: "Cancel".to_owned(), selected_index: 0, action: crate::app::config::ConfirmationAction::MarketplaceRemove, - previous: Box::new(ConfigOverlayState::OutputStyle(OutputStyleOverlayState { - selected: OutputStyle::Default, - })), + previous: Some(Box::new(ConfigOverlayState::OutputStyle( + OutputStyleOverlayState { selected: OutputStyle::Default }, + ))), }, )); diff --git a/src/ui/tool_call/artifact.rs b/src/ui/tool_call/artifact.rs index 4287af42..07b0f221 100644 --- a/src/ui/tool_call/artifact.rs +++ b/src/ui/tool_call/artifact.rs @@ -53,6 +53,9 @@ fn render_input_content(tc: &ToolCallInfo) -> Vec> { if let Some(favicon) = typed::json_string(input, "favicon") { artifact_fields.push(ToolField::new("Favicon", favicon)); } + if let Some(icon) = typed::json_string(input, "icon") { + artifact_fields.push(ToolField::new("Icon", icon)); + } if let Some(url) = typed::json_string(input, "url") { artifact_fields.push(ToolField::new("URL", url)); } @@ -87,6 +90,7 @@ fn render_input_content(tc: &ToolCallInfo) -> Vec> { "description", "file_path", "favicon", + "icon", "url", "prompt", "out_dir", @@ -166,6 +170,7 @@ fn render_output_fields(object: &Map) -> Vec> { if let Some(live_subscription) = typed::json_string(object, "liveSubscription") { artifact_fields.push(ToolField::new("Live subscription", live_subscription)); } + render_publish_state_fields(object, &mut artifact_fields); if let Some(additional) = additional_json( object, &[ @@ -185,6 +190,11 @@ fn render_output_fields(object: &Map) -> Vec> { "updated", "audience", "liveSubscription", + "publishesRemaining", + "publishesResetAt", + "seq", + "unchanged", + "written", "read", "artifactRead", "asset_upload", @@ -204,6 +214,24 @@ fn render_output_fields(object: &Map) -> Vec> { artifact_fields } +fn render_publish_state_fields<'a>( + object: &'a Map, + artifact_fields: &mut Vec>, +) { + if let Some(remaining) = typed::json_i64(object, "publishesRemaining") { + artifact_fields.push(ToolField::new("Publishes remaining", remaining.to_string())); + } + if let Some(reset_at) = typed::json_i64(object, "publishesResetAt") { + artifact_fields.push(ToolField::new("Publish limit resets at", reset_at.to_string())); + } + if let Some(sequence) = typed::json_i64(object, "seq") { + artifact_fields.push(ToolField::new("Publish sequence", sequence.to_string())); + } + if let Some(unchanged) = typed::json_bool(object, "unchanged") { + artifact_fields.push(ToolField::new("Unchanged", typed::bool_label(unchanged))); + } +} + fn render_stored_output_fields(stored: &Map) -> Vec> { let mut fields = Vec::new(); if let Some(contract) = typed::json_string(stored, "contract") { @@ -232,6 +260,9 @@ fn render_stored_output_fields(stored: &Map) -> Vec fn render_output_object(object: &Map) -> Vec> { let mut lines = fields::render_fields(render_output_fields(object)); + if let Some(written) = object.get("written") { + lines.extend(render_written_output(written)); + } if let Some(read) = object.get("read") { lines.extend(render_read_output(read, object.get("artifactRead"))); } else if let Some(artifact_read) = object.get("artifactRead") { @@ -294,6 +325,20 @@ fn render_output_object(object: &Map) -> Vec> { lines } +fn render_written_output(value: &Value) -> Vec> { + let Some(written) = value.as_object() else { + return compact_value_field("Written artifact", value); + }; + let mut values = Vec::new(); + if let Some(url) = typed::json_string(written, "url") { + values.push(ToolField::new("Written artifact", url)); + } + if let Some(additional) = additional_json(written, &["url"]) { + values.push(ToolField::new("Additional written output", additional)); + } + fields::render_fields(values) +} + fn render_watch_output(value: &Value) -> Vec> { let Some(watch) = value.as_object() else { return compact_value_field("Watch", value); @@ -500,7 +545,7 @@ fn render_asset_read_output(value: &Value) -> Vec> { render_asset_fields( asset, "Read asset", - &["id", "path", "content_type", "size_bytes", "sha256"], + &["id", "path", "content_type", "size_bytes", "sha256", "cowritten", "foreign"], ) } @@ -531,6 +576,12 @@ fn render_asset_fields( if let Some(sha256) = typed::json_string(asset, "sha256") { values.push(ToolField::new("SHA-256", sha256)); } + if let Some(cowritten) = typed::json_bool(asset, "cowritten") { + values.push(ToolField::new("Co-written", typed::bool_label(cowritten))); + } + if let Some(foreign) = typed::json_bool(asset, "foreign") { + values.push(ToolField::new("Foreign shared asset", typed::bool_label(foreign))); + } if let Some(additional) = additional_json(asset, handled) { values.push(ToolField::new("Additional asset output", additional)); } @@ -551,6 +602,9 @@ fn render_asset_list_output(value: &Value) -> Vec> { if let Some(next) = typed::json_string(list, "next") { values.push(ToolField::new("Next", next)); } + if let Some(cowritten) = typed::json_bool(list, "cowritten") { + values.push(ToolField::new("Co-written artifact", typed::bool_label(cowritten))); + } let mut lines = fields::render_fields(values); if let Some(assets) = list.get("assets").and_then(Value::as_array) { for (index, asset) in assets.iter().enumerate() { @@ -584,7 +638,9 @@ fn render_asset_list_output(value: &Value) -> Vec> { if let Some(usage) = list.get("usage") { lines.extend(render_asset_usage(usage)); } - if let Some(additional) = additional_json(list, &["url", "assets", "usage", "next"]) { + if let Some(additional) = + additional_json(list, &["url", "assets", "usage", "next", "cowritten"]) + { lines.push(fields::render_field("Additional asset-list output", additional)); } lines @@ -749,6 +805,33 @@ mod tests { ); } + #[test] + fn renders_latest_publish_and_asset_ownership_fields() { + let publish = artifact_tool_call( + json!({"action": "publish", "icon": "chart"}), + Some( + r#"{"url":"https://artifact.local/a","path":"a.html","publishesRemaining":3,"publishesResetAt":1234,"seq":7,"unchanged":true}"#, + ), + ); + let publish_lines = rendered_line_texts(&render_tool_content(&publish)); + assert!(publish_lines.contains(&"Icon: chart".to_owned())); + assert!(publish_lines.contains(&"Publishes remaining: 3".to_owned())); + assert!(publish_lines.contains(&"Publish limit resets at: 1234".to_owned())); + assert!(publish_lines.contains(&"Publish sequence: 7".to_owned())); + assert!(publish_lines.contains(&"Unchanged: yes".to_owned())); + + let asset = artifact_tool_call( + json!({"action": "read_asset", "url": "https://artifact.local/a", "asset_id": "asset-1"}), + Some( + r#"{"written":{"url":"https://artifact.local/a"},"asset_read":{"id":"asset-1","path":"asset.png","size_bytes":4,"content_type":"image/png","sha256":"abc","cowritten":true,"foreign":true}}"#, + ), + ); + let asset_lines = rendered_line_texts(&render_tool_content(&asset)); + assert!(asset_lines.contains(&"Written artifact: https://artifact.local/a".to_owned())); + assert!(asset_lines.contains(&"Co-written: yes".to_owned())); + assert!(asset_lines.contains(&"Foreign shared asset: yes".to_owned())); + } + #[test] fn renders_list_scope_and_each_artifact() { let tc = artifact_tool_call( diff --git a/src/ui/tool_call/projects.rs b/src/ui/tool_call/projects.rs index 287f4507..c75c8a4d 100644 --- a/src/ui/tool_call/projects.rs +++ b/src/ui/tool_call/projects.rs @@ -65,10 +65,156 @@ fn render_output_object(object: &serde_json::Map) -> Some("project_search") => render_project_search_fields(object, &mut project_fields), Some("project_write") => render_project_write_fields(object, &mut project_fields), Some("project_delete") => render_project_delete_fields(object, &mut project_fields), + Some("project_memory_read") => { + render_project_memory_read_fields(object, &mut project_fields); + } _ => {} } - fields::render_fields(project_fields) + let mut lines = fields::render_fields(project_fields); + if typed::json_string(object, "method") == Some("project_memory_read") + && let Some(content) = typed::json_string(object, "content") + { + lines.extend(fields::render_multiline_field("Content", bounded_memory_content(content))); + } + if typed::json_string(object, "method") == Some("project_memory_list") { + lines.extend(render_project_memory_list(object)); + } + lines +} + +fn render_project_memory_read_fields<'a>( + object: &'a serde_json::Map, + project_fields: &mut Vec>, +) { + if let Some(path) = typed::json_string(object, "path") { + project_fields.push(ToolField::new("Path", path.to_owned())); + } + if let Some(local_file) = typed::json_string(object, "local_file") { + project_fields.push(ToolField::new("Local file", local_file.to_owned())); + } + if let Some(size) = typed::json_i64(object, "size_bytes") { + project_fields.push(ToolField::new("Size", format!("{size} bytes"))); + } + if let Some(updated_at) = typed::json_string(object, "updated_at") { + project_fields.push(ToolField::new("Updated", updated_at.to_owned())); + } + if let Some(truncated) = typed::json_bool(object, "truncated") { + project_fields.push(ToolField::new("Truncated", typed::bool_label(truncated))); + } + if let Some(additional) = additional_memory_json( + object, + &[ + "method", + "notice", + "path", + "local_file", + "size_bytes", + "updated_at", + "truncated", + "content", + ], + ) { + project_fields.push(ToolField::new("Additional memory output", additional)); + } +} + +fn render_project_memory_list( + object: &serde_json::Map, +) -> Vec> { + let mut lines = Vec::new(); + if let Some(truncated) = typed::json_bool(object, "truncated") { + lines.push(fields::render_field("List truncated", typed::bool_label(truncated))); + } + let files = object.get("files").and_then(serde_json::Value::as_array); + if let Some(files) = files { + lines.push(fields::render_field("Memory files", files.len().to_string())); + for (index, file) in files.iter().take(128).enumerate() { + let Some(file) = file.as_object() else { + if let Some(value) = typed::non_empty_compact_json(file) { + lines.push(fields::render_dynamic_field( + format!("Memory file {}", index + 1), + value, + )); + } + continue; + }; + let path = typed::json_string(file, "path").unwrap_or(""); + let size = typed::json_i64(file, "size_bytes") + .map(|value| format!("; {value} bytes")) + .unwrap_or_default(); + let updated = typed::json_string(file, "updated_at").map_or_else( + || "; updated unknown".to_owned(), + |value| format!("; updated {value}"), + ); + let truncated = typed::json_bool(file, "truncated") + .filter(|value| *value) + .map(|_| "; truncated".to_owned()) + .unwrap_or_default(); + let additional = + additional_memory_json(file, &["path", "size_bytes", "updated_at", "truncated"]) + .map(|value| format!("; additional {value}")) + .unwrap_or_default(); + lines.push(fields::render_dynamic_field( + format!("Memory file {}", index + 1), + format!("{path}{size}{updated}{truncated}{additional}"), + )); + } + } + if let Some(additional) = + additional_memory_json(object, &["method", "notice", "files", "truncated"]) + { + lines.push(fields::render_field("Additional memory-list output", additional)); + } + lines +} + +fn bounded_memory_content(content: &str) -> String { + const MAX_CHARS: usize = 16_384; + const MAX_LINES: usize = 200; + let mut output = String::new(); + let mut chars = 0; + let mut was_truncated = false; + for (index, line) in content.lines().enumerate() { + if index >= MAX_LINES { + was_truncated = true; + break; + } + let remaining = MAX_CHARS.saturating_sub(chars); + if remaining == 0 { + was_truncated = true; + break; + } + if !output.is_empty() { + output.push('\n'); + chars += 1; + } + let segment = line.chars().take(remaining).collect::(); + chars += segment.chars().count(); + output.push_str(&segment); + if segment.chars().count() < line.chars().count() { + was_truncated = true; + break; + } + } + if was_truncated { + output.push_str("\n… [content truncated for display]"); + } + output +} + +fn additional_memory_json( + object: &serde_json::Map, + handled: &[&str], +) -> Option { + let additional = object + .iter() + .filter(|(key, _)| !handled.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + (!additional.is_empty()) + .then(|| typed::compact_json(&serde_json::Value::Object(additional))) + .flatten() } fn render_project_info_fields<'a>( @@ -234,4 +380,35 @@ mod tests { ] ); } + + #[test] + fn renders_project_memory_list_and_read_metadata() { + let list = projects_tool_call( + json!({"method": "project_memory_list"}), + Some( + r#"{"method":"project_memory_list","truncated":true,"files":[{"path":"memory/notes.md","size_bytes":42,"updated_at":"2026-09-13T10:00:00Z","truncated":true,"future":1}]}"#, + ), + ); + let list_lines = rendered_line_texts(&render_tool_content(&list)); + assert!(list_lines.contains(&"List truncated: yes".to_owned())); + assert!(list_lines.iter().any(|line| line.contains("memory/notes.md") + && line.contains("42 bytes") + && line.contains("future"))); + + let read = projects_tool_call( + json!({"method": "project_memory_read", "path": "memory/notes.md"}), + Some( + r#"{"method":"project_memory_read","path":"memory/notes.md","content":"first\nsecond","size_bytes":12,"updated_at":null,"truncated":false,"future":2}"#, + ), + ); + let read_lines = rendered_line_texts(&render_tool_content(&read)); + assert!(read_lines.contains(&"Path: memory/notes.md".to_owned())); + assert!(read_lines.contains(&"Content: first".to_owned())); + assert!(read_lines.contains(&" second".to_owned())); + assert!( + read_lines + .iter() + .any(|line| line.contains("Additional memory output") && line.contains("future")) + ); + } } diff --git a/src/ui/tool_call/standard.rs b/src/ui/tool_call/standard.rs index ccfaeb63..53a936b8 100644 --- a/src/ui/tool_call/standard.rs +++ b/src/ui/tool_call/standard.rs @@ -109,12 +109,14 @@ pub(super) fn tool_call_has_body(tc: &ToolCallInfo) -> bool { return (renderer.has_structured_body)(tc) || tc.pending_permission.is_some() || tc.pending_question.is_some() + || tc.output_was_staged() || tc.non_execution_metadata().is_some(); } !tc.content.is_empty() || tc.pending_permission.is_some() || tc.pending_question.is_some() + || tc.output_was_staged() || tc.non_execution_metadata().is_some() || renders_structured_ask_user_question_result(tc) || (tc.is_execute_tool() @@ -135,6 +137,12 @@ fn render_standard_body(tc: &ToolCallInfo, width: u16, lines: &mut Vec { diff --git a/src/ui/tool_call/tests.rs b/src/ui/tool_call/tests.rs index 40c7047c..c4cf6c0c 100644 --- a/src/ui/tool_call/tests.rs +++ b/src/ui/tool_call/tests.rs @@ -237,6 +237,22 @@ fn render_tool_call_preserves_non_execution_reason_and_feedback() { assert!(text.contains("Feedback: Use a read-only command.")); } +#[test] +fn render_tool_call_marks_staged_edits_as_file_unchanged() { + let mut tc = test_tool_call("tc-staged", "Write", model::ToolCallStatus::Completed); + tc.output_metadata = Some(model::ToolOutputMetadata::new().staged(true)); + + let mut rendered = Vec::new(); + render_tool_call_cached(&mut tc, ToolCallRenderContext::default(), 100, 0, &mut rendered); + let text = rendered + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("Staged for review: file unchanged")); +} + #[test] fn render_tool_call_title_shows_resolved_model_badge_for_subagents() { let mut tc = test_tool_call("reviewer", "Agent", model::ToolCallStatus::Completed);