From c7f5b8fe768507afa28cdffe5cc3c054c56306ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:44:29 +0000 Subject: [PATCH] feat(deps): bump @anthropic-ai/claude-agent-sdk to 0.3.207 (#874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the minor group with 14 updates: | Package | From | To | | --- | --- | --- | | [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.206` | `0.3.207` | | [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.110.0` | `0.111.0` | | [eslint](https://github.com/eslint/eslint) | `10.6.0` | `10.7.0` | | @anthropic-ai/claude-agent-sdk-darwin-arm64 | `0.3.206` | `0.3.207` | | @anthropic-ai/claude-agent-sdk-darwin-x64 | `0.3.206` | `0.3.207` | | @anthropic-ai/claude-agent-sdk-linux-arm64 | `0.3.206` | `0.3.207` | | @anthropic-ai/claude-agent-sdk-linux-arm64-musl | `0.3.206` | `0.3.207` | | @anthropic-ai/claude-agent-sdk-linux-x64 | `0.3.206` | `0.3.207` | | @anthropic-ai/claude-agent-sdk-linux-x64-musl | `0.3.206` | `0.3.207` | | @anthropic-ai/claude-agent-sdk-win32-arm64 | `0.3.206` | `0.3.207` | | @anthropic-ai/claude-agent-sdk-win32-x64 | `0.3.206` | `0.3.207` | | [es-module-lexer](https://github.com/guybedford/es-module-lexer) | `2.3.0` | `2.3.1` | | [hono](https://github.com/honojs/hono) | `4.12.29` | `4.12.30` | | [postcss](https://github.com/postcss/postcss) | `8.5.16` | `8.5.19` | Updates `@anthropic-ai/claude-agent-sdk` from 0.3.206 to 0.3.207
Release notes

Sourced from @​anthropic-ai/claude-agent-sdk's releases.

v0.3.207

What's changed

Update

npm install @anthropic-ai/claude-agent-sdk@0.3.207
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.207
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.207
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.207
Changelog

Sourced from @​anthropic-ai/claude-agent-sdk's changelog.

0.3.207

Commits

Updates `@anthropic-ai/sdk` from 0.110.0 to 0.111.0
Release notes

Sourced from @​anthropic-ai/sdk's releases.

sdk: v0.111.0

0.111.0 (2026-07-10)

Full Changelog: sdk-v0.110.0...sdk-v0.111.0

Features

Chores

Changelog

Sourced from @​anthropic-ai/sdk's changelog.

0.111.0 (2026-07-10)

Full Changelog: sdk-v0.110.0...sdk-v0.111.0

Features

Chores

Commits

Updates `eslint` from 10.6.0 to 10.7.0
Release notes

Sourced from eslint's releases.

v10.7.0

Features

Bug Fixes

Documentation

Chores

Commits

Updates `@anthropic-ai/claude-agent-sdk-darwin-arm64` from 0.3.206 to 0.3.207 Updates `@anthropic-ai/claude-agent-sdk-darwin-x64` from 0.3.206 to 0.3.207 Updates `@anthropic-ai/claude-agent-sdk-linux-arm64` from 0.3.206 to 0.3.207 Updates `@anthropic-ai/claude-agent-sdk-linux-arm64-musl` from 0.3.206 to 0.3.207 Updates `@anthropic-ai/claude-agent-sdk-linux-x64` from 0.3.206 to 0.3.207 Updates `@anthropic-ai/claude-agent-sdk-linux-x64-musl` from 0.3.206 to 0.3.207 Updates `@anthropic-ai/claude-agent-sdk-win32-arm64` from 0.3.206 to 0.3.207 Updates `@anthropic-ai/claude-agent-sdk-win32-x64` from 0.3.206 to 0.3.207 Updates `es-module-lexer` from 2.3.0 to 2.3.1
Release notes

Sourced from es-module-lexer's releases.

2.3.1

What's Changed

Full Changelog: https://github.com/guybedford/es-module-lexer/compare/2.3.0...2.3.1

Commits

Updates `hono` from 4.12.29 to 4.12.30
Release notes

Sourced from hono's releases.

v4.12.30

What's Changed

Full Changelog: https://github.com/honojs/hono/compare/v4.12.29...v4.12.30

Commits

Updates `postcss` from 8.5.16 to 8.5.19
Release notes

Sourced from postcss's releases.

8.5.19

8.5.18

8.5.17

Changelog

Sourced from postcss's changelog.

8.5.19

8.5.18

8.5.17

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ben Brandt --- package-lock.json | 106 +++--- package.json | 6 +- src/acp-agent.ts | 18 + src/tests/acp-agent-settings.test.ts | 31 +- src/tests/additional-roots.test.ts | 46 +-- src/tests/authorization.test.ts | 30 +- src/tests/create-session-options.test.ts | 39 +- src/tests/helpers.ts | 34 ++ src/tests/session-config-options.test.ts | 16 +- src/tests/tools.test.ts | 457 +++++++++++++++++++++++ src/tools.ts | 248 +++++++++++- 11 files changed, 899 insertions(+), 132 deletions(-) create mode 100644 src/tests/helpers.ts 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":