diff --git a/package-lock.json b/package-lock.json index 8bc07f66..bb7e40b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,20 +10,20 @@ "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "1.2.1", - "@anthropic-ai/claude-agent-sdk": "0.3.206", + "@anthropic-ai/claude-agent-sdk": "0.3.207", "zod": "^3.25.0 || ^4.0.0" }, "bin": { "claude-agent-acp": "dist/index.js" }, "devDependencies": { - "@anthropic-ai/sdk": "0.110.0", + "@anthropic-ai/sdk": "0.111.0", "@eslint/js": "10.0.1", "@tsconfig/node22": "22.0.5", "@types/node": "26.1.1", "@typescript-eslint/eslint-plugin": "8.63.0", "@typescript-eslint/parser": "8.63.0", - "eslint": "10.6.0", + "eslint": "10.7.0", "eslint-config-prettier": "10.1.8", "globals": "17.7.0", "prettier": "3.9.5", @@ -45,22 +45,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.206", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.206.tgz", - "integrity": "sha512-KljDh9Pg4YCYpoXS8dnWoVSsOHtU4yLCW268K2iOruSxFXE8/Tay6DPvmJzYuqjP5YLNYfj05ZGykwZSUn6GXA==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.207.tgz", + "integrity": "sha512-y0PkQRmQBi96MHiN5Xzfq+GaddxCZCqI/cXEQBLYBLXGa4i1nDSlulQqkMBj2RorrrSGQJ6Wdw+uhu6OfHNPzA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.206", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.206", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.206", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.206", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.206", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.206", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.206", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.206" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.207", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.207" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -69,9 +69,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.206", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.206.tgz", - "integrity": "sha512-FL2+NKcMMN47vcnCW2Fkt3AOgeRRlQxrisbPNaxrxqPJFzhUKs17x5j0XzLefd0xRbDAr74hd0PK/tnp6PHM6w==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.207.tgz", + "integrity": "sha512-08xSo1FDx8h0aLhL5tvcRxa2SMmcUV3aDWeZiEJVTclyiDAs61BgTjAxCg+SZcu1CndjJO8cfO0yM5dhamxz3g==", "cpu": [ "arm64" ], @@ -82,9 +82,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.206", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.206.tgz", - "integrity": "sha512-mRW8PPMfQN15EunLwpdmcVzk3XuM4DXQUM8DOzaeA1Hr1yxYPaVVBLr9hkdBKOqr8XTl3ueoTR6RZAy6a/n7OA==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.207.tgz", + "integrity": "sha512-1o7K4EYqyCixZ/oeOZSh7AzSy6TM86xoOuf4VuORjPSS31hBnoqY0NGZd27+2VDs9LGtsdksmsTqcNGx9xd1hA==", "cpu": [ "x64" ], @@ -95,9 +95,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.206", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.206.tgz", - "integrity": "sha512-Id6H8l6EsGb7849EAZDOB4Ic+FQpbzt4D5HGOwp59CTW3o/1c4etjQA/Kl1K+DSEWn3FGo6D5UMVgc/rwhBj8g==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.207.tgz", + "integrity": "sha512-X4uezYOifDiNTTmmugfRCdg3nNamrr1LFRY9hg30vWYTShL+bbN+nfC3KaFfSYCl4GTtsEEUbYdOTC2F3bBpcA==", "cpu": [ "arm64" ], @@ -111,9 +111,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.206", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.206.tgz", - "integrity": "sha512-aMZe1Kl+kYv5QlA15W9Ae25MAzBsA9FA40f5TOtJebA+M/xliF0r2LLb2NdyBviiZCDllcE31l0zgYE0rQqFQw==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.207.tgz", + "integrity": "sha512-oPj+g2DslhH4Y9nCTs7al4t9wZv78FZwLFQwOCg99BXuz1o0ZOpKmxyvR7J9eBR+GPszeMMS8gYplQTiZC9o2w==", "cpu": [ "arm64" ], @@ -127,9 +127,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.206", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.206.tgz", - "integrity": "sha512-egZhOC1RlEVhZyq6Oa1b04AF7hh4hO+8oCsJCZ3gOifJQuHih1oW3lZ8fOUxU85jlT2ytAnt5kRp4uQr6PJgbg==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.207.tgz", + "integrity": "sha512-Kg6BPH8Ee0ny/oEUWJmvT1jCRBne4jVpRSOMsJcYp1Fav1rMEgpU219oJJs+LWwx4ifuuLtNWedqJNnVw7mnKg==", "cpu": [ "x64" ], @@ -143,9 +143,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.206", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.206.tgz", - "integrity": "sha512-xQBOBhlcmTNc7YeYT5qLZOikpSq3WHlsJ/t6i7kJqUWrcXlOPaAoSMdKp5xO/V0CjAdzdJoWB88I55UAh8mclQ==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.207.tgz", + "integrity": "sha512-uRv+D5oG/7EYr41FAJ9IPo2pZYBe2ZMaA6nSHCeizsgPxCSMtl5bNppmU21+jZJvo4hivObEgkGFERAhdGqygg==", "cpu": [ "x64" ], @@ -159,9 +159,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.206", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.206.tgz", - "integrity": "sha512-oRQk23bFXSz4QRhOxqnnvlLWq/KiF2PtSBYXrMg1AQrCOHJd2k66aOAS7AJ4ZSzejiyV4N6sS6UThfmF6ipHBQ==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.207.tgz", + "integrity": "sha512-9fWpUzfkXlPAg2tf8JpQe7w9avFaomAUbfAwyAmykQgSIf66LwaJjvI5hNqhNqczRKyfsXPn3ei2S5HKlmFP+Q==", "cpu": [ "arm64" ], @@ -172,9 +172,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.206", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.206.tgz", - "integrity": "sha512-BdjKmDojZjc5RjN+8Q6K7Yqf1WYhel6I3DkMXc9zdxS/xIuN42sx7zgQpdMGY73pm7ROPLqo3LieOeMhVH161w==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.207.tgz", + "integrity": "sha512-YPjVT0q6aXEM2MgN4CI6/9fqiTXwETji+4NoPOzCYuqAkhXZqp30Jsk7/NHqYGNNSfURKrsuAoliKB0rsbpbjg==", "cpu": [ "x64" ], @@ -185,9 +185,9 @@ ] }, "node_modules/@anthropic-ai/sdk": { - "version": "0.110.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", - "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.111.0.tgz", + "integrity": "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1", @@ -1701,9 +1701,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -1741,9 +1741,9 @@ } }, "node_modules/eslint": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", - "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -2371,9 +2371,9 @@ } }, "node_modules/hono": { - "version": "4.12.29", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.29.tgz", - "integrity": "sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==", + "version": "4.12.30", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", + "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", "license": "MIT", "peer": true, "engines": { @@ -3188,9 +3188,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index dd563650..c22347c5 100644 --- a/package.json +++ b/package.json @@ -64,17 +64,17 @@ }, "dependencies": { "@agentclientprotocol/sdk": "1.2.1", - "@anthropic-ai/claude-agent-sdk": "0.3.206", + "@anthropic-ai/claude-agent-sdk": "0.3.207", "zod": "^3.25.0 || ^4.0.0" }, "devDependencies": { - "@anthropic-ai/sdk": "0.110.0", + "@anthropic-ai/sdk": "0.111.0", "@eslint/js": "10.0.1", "@tsconfig/node22": "22.0.5", "@types/node": "26.1.1", "@typescript-eslint/eslint-plugin": "8.63.0", "@typescript-eslint/parser": "8.63.0", - "eslint": "10.6.0", + "eslint": "10.7.0", "eslint-config-prettier": "10.1.8", "globals": "17.7.0", "prettier": "3.9.5", diff --git a/src/acp-agent.ts b/src/acp-agent.ts index ea41692a..0cbd79ee 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -3318,6 +3318,7 @@ export class ClaudeAcpAgent { taskState: session.taskState, emittedToolCalls: session.emittedToolCalls, messageId: messageIdForGrouping(message), + toolUseResult: message.type === "user" ? message.tool_use_result : undefined, }, )) { // sendUpdate records delivery. Subagent text/thinking is @@ -6302,6 +6303,11 @@ export function toAcpNotifications( // into a single message. Omit it (leave undefined) when unknown — never send // an explicit `null`. messageId?: string; + // The SDK user message's `tool_use_result`: the structured Output object of + // the tool_result this message carries (shape is per-tool). Used to render + // Agent/Task results from the structured subagent report instead of the raw + // text (which ends in a model-directed agentId/usage trailer). + toolUseResult?: unknown; }, ): SessionNotification[] { const taskState = options?.taskState ?? new Map(); @@ -6333,6 +6339,17 @@ export function toAcpNotifications( return [{ sessionId, update }]; } + // `tool_use_result` is message-level and carries no tool_use_id of its own: + // it describes "the" tool_result block of the message it rode in on. If + // several tool_result blocks were ever batched into one message it couldn't + // be attributed, so it is only honored when the message carries exactly one. + const toolUseResult = + options?.toolUseResult !== undefined && + content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result") + .length === 1 + ? options.toolUseResult + : undefined; + const output = []; // Only handle the first chunk for streaming; extend as needed for batching for (const chunk of content) { @@ -6569,6 +6586,7 @@ export function toAcpNotifications( chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput, + toolUseResult, ); // When terminal output is supported, send terminal_output as a diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index dec54ba8..06648599 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; +import { makeMockQuery } from "./helpers.js"; const { querySpy } = vi.hoisted(() => ({ querySpy: vi.fn(), @@ -34,7 +35,7 @@ describe("ClaudeAcpAgent settings", () => { const setModelSpy = vi.fn(); querySpy.mockImplementation(({ options }: any) => { capturedOptions = options; - return { + return makeMockQuery({ initializationResult: async () => ({ models: [ { @@ -45,8 +46,7 @@ describe("ClaudeAcpAgent settings", () => { ], }), setModel: setModelSpy, - supportedCommands: async () => [], - } as any; + }) as any; }); return { getCapturedOptions: () => capturedOptions, setModelSpy }; } @@ -240,12 +240,11 @@ describe("ClaudeAcpAgent settings", () => { const setPermissionModeSpy = vi.fn(); querySpy.mockImplementation(({ options }: any) => { capturedOptions = options; - return { + return makeMockQuery({ initializationResult: async () => ({ models }), setModel: setModelSpy, setPermissionMode: setPermissionModeSpy, - supportedCommands: async () => [], - } as any; + }) as any; }); return { getCapturedOptions: () => capturedOptions, @@ -396,11 +395,10 @@ describe("ClaudeAcpAgent settings", () => { } { const setModelSpy = vi.fn(); querySpy.mockImplementation(() => { - return { + return makeMockQuery({ initializationResult: async () => ({ models }), setModel: setModelSpy, - supportedCommands: async () => [], - } as any; + }) as any; }); return { setModelSpy }; } @@ -875,7 +873,7 @@ describe("ClaudeAcpAgent settings", () => { const setModelSpy = vi.fn(); querySpy.mockImplementation(({ options: _options }: any) => { - return { + return makeMockQuery({ initializationResult: async () => ({ models: [ { @@ -891,8 +889,7 @@ describe("ClaudeAcpAgent settings", () => { ], }), setModel: setModelSpy, - supportedCommands: async () => [], - } as any; + }) as any; }); const { ClaudeAcpAgent } = await import("../acp-agent.js"); @@ -923,7 +920,7 @@ describe("ClaudeAcpAgent settings", () => { const setModelSpy = vi.fn(); querySpy.mockImplementation(() => { - return { + return makeMockQuery({ initializationResult: async () => ({ models: [ { value: "default", displayName: "Default", description: "" }, @@ -932,8 +929,7 @@ describe("ClaudeAcpAgent settings", () => { ], }), setModel: setModelSpy, - supportedCommands: async () => [], - } as any; + }) as any; }); try { @@ -977,7 +973,7 @@ describe("ClaudeAcpAgent settings", () => { const setModelSpy = vi.fn(); querySpy.mockImplementation(() => { - return { + return makeMockQuery({ initializationResult: async () => ({ models: [ { value: "default", displayName: "Default", description: "" }, @@ -985,8 +981,7 @@ describe("ClaudeAcpAgent settings", () => { ], }), setModel: setModelSpy, - supportedCommands: async () => [], - } as any; + }) as any; }); const { ClaudeAcpAgent } = await import("../acp-agent.js"); diff --git a/src/tests/additional-roots.test.ts b/src/tests/additional-roots.test.ts index 408a0b23..c786a638 100644 --- a/src/tests/additional-roots.test.ts +++ b/src/tests/additional-roots.test.ts @@ -7,30 +7,30 @@ import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-age import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; let capturedOptions: Options | undefined; -vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({ - ...(await vi.importActual( +vi.mock("@anthropic-ai/claude-agent-sdk", async () => { + const actual = await vi.importActual( "@anthropic-ai/claude-agent-sdk", - )), - query: ({ options }: { options: Options }) => { - capturedOptions = options; - return { - initializationResult: async () => ({ - models: [ - { - value: "claude-sonnet-4-6", - displayName: "Claude Sonnet", - description: "Fast", - supportsAutoMode: true, - }, - ], - }), - setModel: async () => {}, - setPermissionMode: async () => {}, - supportedCommands: async () => [], - [Symbol.asyncIterator]: async function* () {}, - }; - }, -})); + ); + const { makeMockQuery } = await import("./helpers.js"); + return { + ...actual, + query: ({ options }: { options: Options }) => { + capturedOptions = options; + return makeMockQuery({ + initializationResult: async () => ({ + models: [ + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Fast", + supportsAutoMode: true, + }, + ], + }), + }); + }, + }; +}); vi.mock("../tools.js", async () => ({ ...(await vi.importActual("../tools.js")), registerHookCallback: vi.fn(), diff --git a/src/tests/authorization.test.ts b/src/tests/authorization.test.ts index 954aed1d..4ef0cb8e 100644 --- a/src/tests/authorization.test.ts +++ b/src/tests/authorization.test.ts @@ -1,18 +1,8 @@ import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest"; import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js"; +import { makeMockQuery } from "./helpers.js"; -const mockQuery = vi.hoisted(() => - vi.fn(() => ({ - initializationResult: vi.fn().mockResolvedValue({ - models: [ - { value: "id", displayName: "name", description: "description", supportsAutoMode: true }, - ], - }), - setModel: vi.fn(), - setPermissionMode: vi.fn(), - supportedCommands: vi.fn().mockResolvedValue([]), - })), -); +const mockQuery = vi.hoisted(() => vi.fn()); vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({ ...(await vi.importActual( @@ -24,6 +14,22 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({ describe("authorization", () => { beforeEach(() => { vi.useFakeTimers(); + // Set here (not in vi.fn(impl) at hoist time) so the helper import is + // available; afterEach's resetAllMocks clears it, beforeEach re-sets it. + mockQuery.mockImplementation(() => + makeMockQuery({ + initializationResult: async () => ({ + models: [ + { + value: "id", + displayName: "name", + description: "description", + supportsAutoMode: true, + }, + ], + }), + }), + ); }); afterEach(() => { diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index ec14960c..ab05ab59 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -12,11 +12,12 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { const actual = await vi.importActual( "@anthropic-ai/claude-agent-sdk", ); + const { makeMockQuery, DEFAULT_CONTEXT_USAGE } = await import("./helpers.js"); return { ...actual, query: (args: { prompt: unknown; options: Options }) => { capturedOptions = args.options; - return { + return makeMockQuery({ initializationResult: async () => ({ models: [ { @@ -27,15 +28,9 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { }, ], }), - setModel: async () => {}, - setPermissionMode: async () => {}, - supportedCommands: async () => [], getContextUsage: () => - contextUsageResult - ? contextUsageResult() - : Promise.reject(new Error("no context usage mocked")), - [Symbol.asyncIterator]: async function* () {}, - }; + contextUsageResult ? contextUsageResult() : Promise.resolve(DEFAULT_CONTEXT_USAGE), + }); }, }; }); @@ -520,8 +515,13 @@ describe("createSession options merging", () => { it("ignores a non-numeric value", async () => { process.env.MAX_THINKING_TOKENS = "lots"; + // The bad value is deliberate — capture the agent's warning instead of + // letting it hit the console. + const errorSpy = vi.fn(); + (agent as any).logger = { log: () => {}, error: errorSpy }; await agent.newSession({ cwd: process.cwd(), mcpServers: [] }); expect(capturedOptions!.thinking).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("MAX_THINKING_TOKENS")); }); it("lets a user-provided thinking option override the env default", async () => { @@ -637,11 +637,18 @@ describe("createSession options merging", () => { }); it("falls back to the default window when getContextUsage fails and inference misses", async () => { - // The default mock rejects getContextUsage, and the mock model - // ("claude-sonnet-4-6" / "Claude Sonnet" / "Fast") has no "1m" hint. + // getContextUsage rejects, and the mock model ("claude-sonnet-4-6" / + // "Claude Sonnet" / "Fast") has no "1m" hint. + contextUsageResult = () => Promise.reject(new Error("no context usage mocked")); + // The rejection is deliberate — capture the agent's warning instead of + // letting it hit the console. + const errorSpy = vi.fn(); + (agent as any).logger = { log: () => {}, error: errorSpy }; + const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] }); expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000); + expect(errorSpy).toHaveBeenCalled(); }); it("ignores a nonsensical (non-positive) reported window", async () => { @@ -750,6 +757,10 @@ describe("createSession options merging", () => { it("cancels on a malformed payload without presenting anything", async () => { const { onUserDialog, createElicitation } = await setupDialog(); + // The malformed payload is deliberate — capture the agent's warning + // instead of letting it hit the console. + const errorSpy = vi.fn(); + (agent as any).logger = { log: () => {}, error: errorSpy }; const result = await onUserDialog( { dialogKind: "refusal_fallback_prompt", payload: { fallbackModel: 42 } }, @@ -758,11 +769,16 @@ describe("createSession options merging", () => { expect(result).toEqual({ behavior: "cancelled" }); expect(createElicitation).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("unexpected shape")); }); it("cancels when the elicitation request fails", async () => { const { onUserDialog, createElicitation } = await setupDialog(); createElicitation.mockRejectedValue(new Error("client exploded")); + // The client failure is deliberate — capture the agent's warning + // instead of letting it hit the console. + const errorSpy = vi.fn(); + (agent as any).logger = { log: () => {}, error: errorSpy }; const result = await onUserDialog( { @@ -773,6 +789,7 @@ describe("createSession options merging", () => { ); expect(result).toEqual({ behavior: "cancelled" }); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("client exploded")); }); }); }); diff --git a/src/tests/helpers.ts b/src/tests/helpers.ts new file mode 100644 index 00000000..fe196c5c --- /dev/null +++ b/src/tests/helpers.ts @@ -0,0 +1,34 @@ +/** + * Shared test doubles. Deliberately vitest-free so `vi.mock` async factories + * can `await import("./helpers.js")` without ordering hazards; tests supply + * their own vi.fn spies via `overrides`. + */ + +/** The context-usage report the base mock query returns. `rawMaxTokens` + * matches the agent's DEFAULT_CONTEXT_WINDOW so window-related assertions + * don't shift in tests that don't care about context usage. */ +export const DEFAULT_CONTEXT_USAGE = { totalTokens: 0, rawMaxTokens: 200000 }; + +/** + * Base stub for the SDK `query()` return object, covering the surface + * ClaudeAcpAgent touches unconditionally at session creation. Tests pass + * `overrides` for the parts they assert on (spies, custom models, rejecting + * getContextUsage, …). + * + * When the agent starts calling a new SDK method on every session, add it + * here once — the getContextUsage adoption required hand-editing ~10 inline + * mocks across five files, and any missed copy didn't fail: it silently + * rerouted that test through the error-fallback branch and re-polluted test + * output. + */ +export function makeMockQuery(overrides: Record = {}) { + return { + initializationResult: async () => ({ models: [] }), + setModel: async () => {}, + setPermissionMode: async () => {}, + supportedCommands: async () => [], + getContextUsage: async () => DEFAULT_CONTEXT_USAGE, + [Symbol.asyncIterator]: async function* () {}, + ...overrides, + }; +} diff --git a/src/tests/session-config-options.test.ts b/src/tests/session-config-options.test.ts index aff7158b..d5e59616 100644 --- a/src/tests/session-config-options.test.ts +++ b/src/tests/session-config-options.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { SessionNotification } from "@agentclientprotocol/sdk"; import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; +import { makeMockQuery } from "./helpers.js"; const { registerHookCallbackSpy } = vi.hoisted(() => ({ registerHookCallbackSpy: vi.fn(), @@ -101,12 +102,11 @@ describe("session config options", () => { applyFlagSettingsSpy = vi.fn(); (agent as unknown as { sessions: Record }).sessions[SESSION_ID] = { - query: { + query: makeMockQuery({ setPermissionMode: setPermissionModeSpy, setModel: setModelSpy, applyFlagSettings: applyFlagSettingsSpy, - supportedCommands: async () => [], - }, + }), input: null, cancelled: false, permissionMode: "default", @@ -956,6 +956,10 @@ describe("session config options", () => { session.modelInfos = session.modelInfos.map((m: ModelInfo) => m.value === "claude-sonnet-4-6" ? { ...m, resolvedModel: "claude-sonnet-5[1m]" } : m, ); + // The rejection is deliberate — capture the agent's warning instead of + // letting it hit the console. + const errorSpy = vi.fn(); + (agent as any).logger = { log: () => {}, error: errorSpy }; await agent.setSessionConfigOption({ sessionId: SESSION_ID, @@ -964,12 +968,17 @@ describe("session config options", () => { }); expect(session.contextWindowSize).toBe(1_000_000); + expect(errorSpy).toHaveBeenCalled(); }); it("falls back to the default window when the SDK and inference both miss", async () => { const session = getSession(); session.contextWindowSize = 1_000_000; session.query.getContextUsage = vi.fn().mockRejectedValue(new Error("boom")); + // The rejection is deliberate — capture the agent's warning instead of + // letting it hit the console. + const errorSpy = vi.fn(); + (agent as any).logger = { log: () => {}, error: errorSpy }; await agent.setSessionConfigOption({ sessionId: SESSION_ID, @@ -978,6 +987,7 @@ describe("session config options", () => { }); expect(session.contextWindowSize).toBe(200000); + expect(errorSpy).toHaveBeenCalled(); }); it("keeps the learned window when re-asserting the current model", async () => { diff --git a/src/tests/tools.test.ts b/src/tests/tools.test.ts index 76300923..76a12411 100644 --- a/src/tests/tools.test.ts +++ b/src/tests/tools.test.ts @@ -2205,3 +2205,460 @@ describe("empty message content is not emitted", () => { }); }); }); + +describe("Agent/Task tool_result rendering from tool_use_result", () => { + const mockClient = {} as AcpClient; + const mockLogger: Logger = { log: () => {}, error: () => {} }; + + const TRAILER = + "\nagentId: a0e1eff08fcb6e2e8 (use SendMessage with to: 'a0e1eff08fcb6e2e8', summary: '<5-10 word recap>' to continue this agent)\nsubagent_tokens: 11735\ntool_uses: 2\nduration_ms: 21237"; + + const agentToolUse = { + type: "tool_use" as const, + id: "toolu_agent", + name: "Task", + input: { description: "Explore", prompt: "look around" }, + }; + + const rawResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_agent", + content: [{ type: "text", text: `The report.${TRAILER}` }], + }; + + const structured = { + status: "completed", + agentId: "a0e1eff08fcb6e2e8", + content: [{ type: "text", text: "The structured report." }], + totalTokens: 11735, + totalToolUseCount: 2, + totalDurationMs: 21237, + }; + + it("renders the structured subagent report instead of the raw trailer text", () => { + const update = toolUpdateFromToolResult(rawResult, agentToolUse, false, structured); + + expect(update).toEqual({ + content: [{ type: "content", content: { type: "text", text: "The structured report." } }], + }); + }); + + it("strips the trailer from the raw fallback when tool_use_result is absent", () => { + // Replayed sessions and older CLIs have no structured report; the + // tail-anchored strip is the only cleanup available there. + const update = toolUpdateFromToolResult(rawResult, agentToolUse, false); + + expect(update).toEqual({ + content: [{ type: "content", content: { type: "text", text: "The report." } }], + }); + }); + + it("strips only matching trailer parts and leaves unrecognized text alone", () => { + const oddResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_agent", + content: [ + { type: "text", text: "Report A.\nsubagent_tokens: 5" }, + { type: "text", text: "Report B.\nagentId: abc-123 (for resuming)" }, + { type: "text", text: "agentId mentioned mid-text (not a trailer) stays.\nDone." }, + ], + }; + const update = toolUpdateFromToolResult(oddResult, agentToolUse, false); + + expect(update.content).toEqual([ + { type: "content", content: { type: "text", text: "Report A." } }, + { type: "content", content: { type: "text", text: "Report B." } }, + { + type: "content", + content: { type: "text", text: "agentId mentioned mid-text (not a trailer) stays.\nDone." }, + }, + ]); + }); + + it("falls back (trailer-stripped) when tool_use_result is the async_launched variant", () => { + const update = toolUpdateFromToolResult(rawResult, agentToolUse, false, { + status: "async_launched", + agentId: "a0e1eff08fcb6e2e8", + description: "Explore", + }); + + expect(update.content).toEqual([ + { type: "content", content: { type: "text", text: "The report." } }, + ]); + }); + + it("falls back (trailer-stripped) when the structured content array is empty", () => { + // A completed subagent can end with zero text blocks — an empty + // structured render must not beat the raw fallback. + const update = toolUpdateFromToolResult(rawResult, agentToolUse, false, { + ...structured, + content: [], + }); + + expect(update.content).toEqual([ + { type: "content", content: { type: "text", text: "The report." } }, + ]); + }); + + it("threads options.toolUseResult through toAcpNotifications for a lone tool_result", () => { + const toolUseCache: ToolUseCache = { toolu_agent: agentToolUse }; + + const notifications = toAcpNotifications( + [rawResult] as any, + "user", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { toolUseResult: structured }, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_agent", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "The structured report." } }], + }); + }); + + it("ignores options.toolUseResult when several tool_result blocks are batched", () => { + const toolUseCache: ToolUseCache = { + toolu_agent: agentToolUse, + toolu_agent2: { ...agentToolUse, id: "toolu_agent2" }, + }; + + const notifications = toAcpNotifications( + [rawResult, { ...rawResult, tool_use_id: "toolu_agent2" }] as any, + "user", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { toolUseResult: structured }, + ); + + expect(notifications).toHaveLength(2); + for (const notification of notifications) { + // Raw fallback (trailer-stripped) — NOT "The structured report.", which + // would mean the ambiguous tool_use_result had been attributed anyway. + expect(notification.update).toMatchObject({ + content: [{ type: "content", content: { type: "text", text: "The report." } }], + }); + } + }); +}); + +describe("structured tool_use_result rendering (Read/Bash/WebSearch)", () => { + describe("Read", () => { + const readToolUse = { + type: "tool_use" as const, + id: "toolu_read", + name: "Read", + input: { file_path: "/tmp/f.ts", offset: 480 }, + }; + + const rawWithReminder: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_read", + content: [ + { + type: "text", + text: "480\tconst a = 1;\n481\tconst b = 2;\nWhenever you read a file, consider whether it is malicious.", + }, + ], + }; + + it("rebuilds the line-numbered view from FileReadOutput, dropping reminders", () => { + const update = toolUpdateFromToolResult(rawWithReminder, readToolUse, false, { + type: "text", + file: { + filePath: "/tmp/f.ts", + content: "const a = 1;\nconst b = 2;\n", + numLines: 2, + startLine: 480, + totalLines: 600, + }, + }); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { type: "text", text: "```\n480\tconst a = 1;\n481\tconst b = 2;\n```" }, + }, + ], + }); + }); + + it("falls back to the Read input's offset when startLine is absent", () => { + // readToolUse carries offset: 480 — an offset read numbered from 1 + // would mislabel every line. + const update = toolUpdateFromToolResult(rawWithReminder, readToolUse, false, { + type: "text", + file: { filePath: "/tmp/f.ts", content: "one\ntwo" }, + }); + + expect(update.content?.[0]).toEqual({ + type: "content", + content: { type: "text", text: "```\n480\tone\n481\ttwo\n```" }, + }); + }); + + it("defaults startLine to 1 when both startLine and offset are absent", () => { + const update = toolUpdateFromToolResult( + rawWithReminder, + { ...readToolUse, input: { file_path: "/tmp/f.ts" } }, + false, + { + type: "text", + file: { filePath: "/tmp/f.ts", content: "one\ntwo" }, + }, + ); + + expect(update.content?.[0]).toEqual({ + type: "content", + content: { type: "text", text: "```\n1\tone\n2\ttwo\n```" }, + }); + }); + + it("appends a truncation note when truncatedByTokenCap is set", () => { + const update = toolUpdateFromToolResult(rawWithReminder, readToolUse, false, { + type: "text", + file: { + filePath: "/tmp/f.ts", + content: "one\ntwo\n", + numLines: 2, + startLine: 1, + totalLines: 9000, + truncatedByTokenCap: true, + }, + }); + + expect(update.content?.[0]).toEqual({ + type: "content", + content: { + type: "text", + text: "```\n1\tone\n2\ttwo\n[File truncated: showing 2 of 9000 lines]\n```", + }, + }); + }); + + it("falls back to raw content for non-text variants", () => { + const update = toolUpdateFromToolResult(rawWithReminder, readToolUse, false, { + type: "image", + file: { base64: "aGk=", type: "image/png", originalSize: 3 }, + }); + + // Raw path: markdown-escaped raw text (reminder included — image reads + // don't carry reminders in practice). + expect(update.content).toHaveLength(1); + expect((update.content?.[0] as any).content.text).toContain("const a = 1;"); + }); + }); + + describe("Bash", () => { + const bashToolUse = { + type: "tool_use" as const, + id: "toolu_bash", + name: "Bash", + input: { command: "git push" }, + }; + + const HINT = + "\n[This command modified 1 file you've previously read: src/foo.ts. Call Read before editing.]"; + + const rawWithHint: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_bash", + content: `pushed ok${HINT}`, + }; + + const structured = { + stdout: "pushed ok", + stderr: "", + interrupted: false, + isImage: false, + }; + + it("prefers structured stdout/stderr over raw text with model-directed hints", () => { + const update = toolUpdateFromToolResult(rawWithHint, bashToolUse, true, structured); + + expect(update._meta?.terminal_output).toEqual({ + terminal_id: "toolu_bash", + data: "pushed ok", + }); + expect(update._meta?.terminal_exit).toEqual({ + terminal_id: "toolu_bash", + exit_code: 0, + signal: null, + }); + }); + + it("joins stderr after stdout like the code-execution path", () => { + const update = toolUpdateFromToolResult(rawWithHint, bashToolUse, false, { + ...structured, + stderr: "warning: something", + }); + + expect(update.content).toEqual([ + { + type: "content", + content: { type: "text", text: "```console\npushed ok\nwarning: something\n```" }, + }, + ]); + }); + + it("falls back to raw text for backgrounded commands", () => { + const update = toolUpdateFromToolResult(rawWithHint, bashToolUse, true, { + ...structured, + stdout: "", + backgroundTaskId: "bash_1", + }); + + expect(update._meta?.terminal_output?.data).toBe(`pushed ok${HINT}`); + }); + + it("falls back to the raw content array for image output", () => { + const imageResult: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_bash", + content: [ + { type: "image", source: { type: "base64", data: "aGk=", media_type: "image/png" } }, + ], + }; + const update = toolUpdateFromToolResult(imageResult, bashToolUse, true, { + ...structured, + isImage: true, + }); + + expect(update.content).toEqual([ + { + type: "content", + content: { type: "image", data: "aGk=", mimeType: "image/png" }, + }, + ]); + }); + + it("re-establishes the abort notice and a failing exit code for interrupted commands", () => { + const update = toolUpdateFromToolResult(rawWithHint, bashToolUse, true, { + ...structured, + stdout: "partial output", + interrupted: true, + }); + + expect(update._meta?.terminal_output).toEqual({ + terminal_id: "toolu_bash", + data: "partial output\n[Command was aborted before completion]", + }); + expect(update._meta?.terminal_exit).toEqual({ + terminal_id: "toolu_bash", + exit_code: 1, + signal: null, + }); + }); + + it("re-establishes the truncation note and persisted path for too-large outputs", () => { + const update = toolUpdateFromToolResult(rawWithHint, bashToolUse, true, { + ...structured, + stdout: "clipped stdout", + persistedOutputPath: "/tmp/tool-results/abc.txt", + persistedOutputSize: 38100, + }); + + expect(update._meta?.terminal_output).toEqual({ + terminal_id: "toolu_bash", + data: "clipped stdout\n[Output truncated (38100 bytes total): full output saved to /tmp/tool-results/abc.txt]", + }); + }); + }); + + describe("WebSearch", () => { + const searchToolUse = { + type: "tool_use" as const, + id: "toolu_search", + name: "WebSearch", + input: { query: "npm sigstore bug" }, + }; + + const rawDump: ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "toolu_search", + content: + 'Web search results for query: "npm sigstore bug"\n\nLinks: [{"title":"Issue #9722","url":"https://github.com/npm/cli/issues/9722"}]', + }; + + it("renders hits as Title (url) lines from WebSearchOutput", () => { + const update = toolUpdateFromToolResult(rawDump, searchToolUse, false, { + query: "npm sigstore bug", + durationSeconds: 5.5, + results: [ + "I found one relevant issue:", + { + tool_use_id: "srvtoolu_1", + content: [ + { title: "Issue #9722", url: "https://github.com/npm/cli/issues/9722" }, + { title: "sigstore-js", url: "https://github.com/sigstore/sigstore-js" }, + ], + }, + ], + }); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { + type: "text", + text: "I found one relevant issue:\nIssue #9722 (https://github.com/npm/cli/issues/9722)\nsigstore-js (https://github.com/sigstore/sigstore-js)", + }, + }, + ], + }); + }); + + it("falls back to the raw dump when tool_use_result is absent", () => { + const update = toolUpdateFromToolResult(rawDump, searchToolUse, false); + + expect((update.content?.[0] as any).content.text).toContain("Web search results for query"); + }); + + it("skips off-spec hits instead of rendering undefined fields", () => { + const update = toolUpdateFromToolResult(rawDump, searchToolUse, false, { + query: "npm sigstore bug", + durationSeconds: 5.5, + results: [ + { + tool_use_id: "srvtoolu_1", + content: [ + { error_code: "provider_error" }, + { title: "Issue #9722", url: "https://github.com/npm/cli/issues/9722" }, + ], + }, + ], + }); + + expect(update).toEqual({ + content: [ + { + type: "content", + content: { + type: "text", + text: "Issue #9722 (https://github.com/npm/cli/issues/9722)", + }, + }, + ], + }); + }); + + it("falls back to the raw dump when every hit is off-spec", () => { + const update = toolUpdateFromToolResult(rawDump, searchToolUse, false, { + query: "npm sigstore bug", + durationSeconds: 5.5, + results: [{ tool_use_id: "srvtoolu_1", content: [{ error_code: "provider_error" }] }], + }); + + expect((update.content?.[0] as any).content.text).toContain("Web search results for query"); + }); + }); +}); diff --git a/src/tools.ts b/src/tools.ts index 1ee2ab88..14cb7450 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -8,10 +8,13 @@ import { import { HookCallback } from "@anthropic-ai/claude-agent-sdk"; import { AgentInput, + AgentOutput, AskUserQuestionInput, BashInput, + BashOutput, FileEditInput, FileReadInput, + FileReadOutput, FileWriteInput, GlobInput, GrepInput, @@ -22,6 +25,7 @@ import { TodoWriteInput, WebFetchInput, WebSearchInput, + WebSearchOutput, } from "@anthropic-ai/claude-agent-sdk/sdk-tools.js"; import { ImageBlockParam, @@ -486,6 +490,55 @@ export function toolInfoFromToolUse( } } +/** + * Narrow the untyped message-level `tool_use_result` toward a per-tool Output + * shape: rejects everything but a plain non-null object (arrays pass a bare + * `typeof === "object"` check, so they're excluded here). The returned value + * is only *nominally* typed — it arrives over the wire from arbitrary CLI + * versions, so each caller must still guard the specific fields it reads + * before trusting them. + */ +function structuredResult(toolUseResult: unknown): T | undefined { + return toolUseResult !== null && + typeof toolUseResult === "object" && + !Array.isArray(toolUseResult) + ? (toolUseResult as T) + : undefined; +} + +/** + * Strip the model-directed trailer from a raw Agent/Task tool_result text: + * a `…` totals block and/or an + * `agentId: (use SendMessage …)` continuation line at the end of the + * text. Both patterns are tail-anchored and independent (older CLIs emit + * variants with only one of them), so a format change makes them stop + * matching rather than mangle the report. + */ +function stripAgentTrailer(text: string): string { + return text + .replace(/\n?[\s\S]*?<\/usage>\s*$/, "") + .replace(/\n?agentId: [\w-]+ \([^)]*\)\s*$/, ""); +} + +/** Apply {@link stripAgentTrailer} across a raw tool_result `content` (plain + * string or block array), leaving non-text blocks untouched. */ +function stripAgentTrailerFromContent(content: unknown): unknown { + if (typeof content === "string") { + return stripAgentTrailer(content); + } + if (Array.isArray(content)) { + return content.map((block) => + block !== null && + typeof block === "object" && + block.type === "text" && + typeof block.text === "string" + ? { ...block, text: stripAgentTrailer(block.text) } + : block, + ); + } + return content; +} + export function toolUpdateFromToolResult( toolResult: | ToolResultBlockParam @@ -500,6 +553,7 @@ export function toolUpdateFromToolResult( | BetaToolSearchToolResultBlockParam, toolUse: any | undefined, supportsTerminalOutput: boolean = false, + toolUseResult?: unknown, ): ToolUpdate { if ( "is_error" in toolResult && @@ -512,8 +566,63 @@ export function toolUpdateFromToolResult( return toAcpContentUpdate(toolResult.content, true); } + // Shared raw-text fallback: renders the tool_result content the model saw. + // The structured cases below fall back to this when `tool_use_result` is + // absent or fails its shape guard (older CLIs, replayed sessions). + const rawContentUpdate = () => + toAcpContentUpdate(toolResult.content, "is_error" in toolResult ? toolResult.is_error : false); + switch (toolUse?.name) { - case "Read": + case "Read": { + // The raw tool_result text is the model-facing view: line-numbered + // content plus any appended blocks (malicious-code + // checks, memory staleness notes, …) that clients shouldn't see. The + // structured FileReadOutput carries the clean content — rebuild the + // line-numbered view from it. Non-text variants (image/notebook/pdf) + // fall back to the raw content blocks, which already render fine. + const structuredRead = structuredResult(toolUseResult); + if ( + structuredRead?.type === "text" && + typeof structuredRead.file?.content === "string" && + // An empty file has nothing to line-number; keep the raw view (the + // model-facing "file is empty" note) rather than a phantom blank line. + structuredRead.file.content.length > 0 + ) { + // startLine is typed non-optional but defended anyway; a Read's + // `offset` input is the same 1-based starting line, so it beats a + // blind 1 when an emitter omits the field. + const startLine = + structuredRead.file.startLine ?? + (toolUse.input as FileReadInput | undefined)?.offset ?? + 1; + // A trailing newline is a line terminator, not an extra line — don't + // number a phantom empty line after it. + let numbered = structuredRead.file.content + .replace(/\n$/, "") + .split("\n") + .map((line, i) => `${startLine + i}\t${line}`) + .join("\n"); + // The model-facing truncation banner doesn't survive reconstruction + // from file.content (the SDK flag exists for exactly this case) — + // re-establish it so a partial first page doesn't read as the whole + // file. + if (structuredRead.file.truncatedByTokenCap) { + const { numLines, totalLines } = structuredRead.file; + const detail = + typeof numLines === "number" && typeof totalLines === "number" + ? `: showing ${numLines} of ${totalLines} lines` + : ""; + numbered += `\n[File truncated${detail}]`; + } + return { + content: [ + { + type: "content", + content: { type: "text", text: markdownEscape(numbered) }, + }, + ], + }; + } if (Array.isArray(toolResult.content) && toolResult.content.length > 0) { return { content: toolResult.content.map((content: any) => ({ @@ -541,6 +650,7 @@ export function toolUpdateFromToolResult( }; } return {}; + } case "Bash": { const result = toolResult.content; @@ -548,15 +658,57 @@ export function toolUpdateFromToolResult( const isError = "is_error" in toolResult && toolResult.is_error; // Extract output and exit code from either format: - // 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code } - // 2. Plain string content from a regular tool_result - // 3. Array content (e.g. [{ type: "text", text: "..." }] for stdout, + // 1. The structured BashOutput (message-level tool_use_result): its + // stdout/stderr exclude the model-directed suffixes the raw text + // carries (stale-read hints, gh rate-limit hints, the + // persisted-output wrapper for too-large outputs — the interruption + // and truncation facts those carried are re-established from the + // structured flags below). Skipped for image output (the raw content + // array carries the actual image blocks) and backgrounded commands + // (the raw text carries the background-task notice; structured + // stdout may be empty). + // 2. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code } + // 3. Plain string content from a regular tool_result + // 4. Array content (e.g. [{ type: "text", text: "..." }] for stdout, // or [{ type: "image", source: {...} }] when the local Bash tool // produces an image, e.g. piping a base64 data URI) let output = ""; let exitCode = isError ? 1 : 0; + const structuredBash = structuredResult(toolUseResult); if ( + structuredBash && + typeof structuredBash.stdout === "string" && + typeof structuredBash.stderr === "string" && + !structuredBash.isImage && + structuredBash.backgroundTaskId === undefined + ) { + output = [structuredBash.stdout, structuredBash.stderr].filter(Boolean).join("\n"); + // Two raw-text notices don't survive the structured stdout/stderr — + // re-establish them so the client isn't shown a clean-looking result: + // the CLI appends its abort marker only to the model-facing text, and + // an aborted command isn't a success, so synthesize a failing exit + // code when the result wasn't already an error. + if (structuredBash.interrupted) { + output = [output, "[Command was aborted before completion]"].filter(Boolean).join("\n"); + exitCode = 1; + } + // Structured stdout is clipped (~30k chars) when the full output was + // persisted to disk; without this note the clip is silent and the + // path to the full output is lost. + if (typeof structuredBash.persistedOutputPath === "string") { + const size = + typeof structuredBash.persistedOutputSize === "number" + ? ` (${structuredBash.persistedOutputSize} bytes total)` + : ""; + output = [ + output, + `[Output truncated${size}: full output saved to ${structuredBash.persistedOutputPath}]`, + ] + .filter(Boolean) + .join("\n"); + } + } else if ( result && typeof result === "object" && "type" in result && @@ -619,6 +771,45 @@ export function toolUpdateFromToolResult( return {}; } + case "Agent": + case "Task": { + // The raw tool_result text ends with a model-directed trailer (an + // `agentId: … (use SendMessage …)` line plus a `` totals block) + // that ACP clients shouldn't see. The message-level `tool_use_result` + // carries the structured AgentOutput whose `content` is the subagent's + // report without the trailer — render from it when present (per the SDK + // 0.3.207 guidance) and fall back to the raw text otherwise (older CLIs, + // replayed sessions). + // Narrowed to the full union, not the completed variant — the status + // check below is what discriminates it, and pre-narrowing would let + // future field reads typecheck against a variant the runtime value may + // not be. + const structured = structuredResult(toolUseResult); + if ( + structured?.status === "completed" && + Array.isArray(structured.content) && + // A completed subagent can end with zero text blocks; an empty + // structured render would beat the raw fallback for no benefit. + structured.content.length > 0 + ) { + return toAcpContentUpdate( + structured.content, + "is_error" in toolResult ? toolResult.is_error : false, + ); + } + // No structured report to render from (replayed sessions — + // getSessionMessages doesn't expose the transcript's toolUseResult — + // and older CLIs). The SDK advises rendering from tool_use_result + // instead of parsing the text, but with no structured value the + // tail-anchored strip is the only cleanup available; if the trailer + // format changes it simply stops matching and the full raw text + // renders, no worse than before. + return toAcpContentUpdate( + stripAgentTrailerFromContent(toolResult.content), + "is_error" in toolResult ? toolResult.is_error : false, + ); + } + case "Edit": // Edit is handled in hooks case "Write": { return {}; @@ -628,15 +819,54 @@ export function toolUpdateFromToolResult( return { title: "Exited Plan Mode" }; } + case "WebSearch": { + // The raw tool_result text is a model-directed dump ("Web search + // results for query: …\n\nLinks: [{…json…}]"). The structured + // WebSearchOutput carries the hits — render them the way server-side + // web_search_result blocks render ("Title (url)"). + const structuredSearch = structuredResult(toolUseResult); + if (structuredSearch && Array.isArray(structuredSearch.results)) { + const lines = structuredSearch.results.flatMap((entry) => + typeof entry === "string" + ? [entry] + : Array.isArray(entry?.content) + ? // tool_use_result arrives untyped across CLI version skew — + // skip off-spec hits rather than rendering + // "undefined (undefined)" lines. + entry.content.flatMap((hit) => + typeof hit?.title === "string" && typeof hit?.url === "string" + ? [formatWebSearchHit(hit)] + : [], + ) + : [], + ); + if (lines.length > 0) { + return { + content: [ + { + type: "content", + content: { type: "text", text: lines.join("\n") }, + }, + ], + }; + } + } + return rawContentUpdate(); + } + default: { - return toAcpContentUpdate( - toolResult.content, - "is_error" in toolResult ? toolResult.is_error : false, - ); + return rawContentUpdate(); } } } +/** One display format for a web-search hit, shared by the structured + * WebSearchOutput render and the server-side `web_search_result` block so + * the two paths can't drift. */ +function formatWebSearchHit(hit: { title: string; url: string }): string { + return `${hit.title} (${hit.url})`; +} + function toAcpContentUpdate( content: any, isError: boolean = false, @@ -711,7 +941,7 @@ function toAcpContentBlock(content: ToolResultContent, isError: boolean): Conten `Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`, ); case "web_search_result": - return wrapText(`${content.title} (${content.url})`); + return wrapText(formatWebSearchHit(content)); case "web_search_tool_result_error": return wrapText(`Error: ${content.error_code}`); case "web_fetch_result":