Compare commits

...

6 Commits

Author SHA1 Message Date
mark.tkachenko
a39e511c97 refactor: move AcpClient and Logger out of acp-agent.ts 2026-11-09 13:02:48 +01:00
Ben Brandt
a58293f4c9 fix: update to @anthropic-ai/claude-agent-sdk 0.3.319 (#1545)
Updates the Claude Agent SDK from 0.3.318 to 0.3.319, which bundles
Claude Code 2.1.319. Also updates `@anthropic-ai/sdk` (1.4.0 → 1.6.0)
and the transitive dependencies in the lockfile (`@babel/runtime`,
`@eslint-community/regexpp`, `@rolldown/pluginutils`, `hono`, `jose`,
`vite`).

## Reviewed, no change needed

- **`mcp_task` background tasks:** SDK sessions get these only with
`CLAUDE_AUTO_BACKGROUND_TASKS=1` and streaming input. The adapter does
not set that variable, so they don't occur by default. If a user sets
it, the Async Tasks panel shows the raw `mcp_task` type, and the
`resource_links` on the task notification are not forwarded. Left as a
possible follow-up.
- **`deleteSession()` rejects with `ENOTEMPTY`** when the session folder
is only partly removed. `session/delete` already rethrows any error
other than "not found".
- **`session_writes_lost`** is now sent only when the CLI exits 0. The
adapter only logs it.
- **`permissions.denyInsteadOfPrompting`:** with it set, the CLI denies
instead of calling `canUseTool`. That is the setting's intent.
- **`mcp_call.check_permissions`, `set_has_subscribers`,
`disableWindowsBashCommandSplit`:** the adapter does not use these.
- The comments on the `getSettings` and `unanswered` type workarounds
now name 0.3.319; both are still missing from the SDK's types.

## Not updated

TypeScript stays on 6.0.3: typescript-eslint 8.72.1 requires `typescript
<6.1.0`, so 7.0.2 has to wait for a typescript-eslint release that
supports it.

## Testing

`npm run build`, `npm run check` and `npm run test:run` pass. `npm run
test:integration` was not run.
2026-11-09 08:56:37 +00:00
PerryLink
3b22586cf6 fix(test): assert the blockquoted MCP startup transcript line (#1543)
`#1362` moved every transcript fallback for clients without
`session.notices` into its own blockquote paragraph (`\n\n> …\n\n`) and
updated the existing assertions, but missed the MCP server startup one.
`main` has been red since that merge:

- The `Build` job on `47b64c0` fails with an annotation at
`src/tests/acp-agent.test.ts` line 13860:

  ```
AssertionError: expected '\n\n> **MCP server "client-fs" failed…' to be
'**MCP server "client-fs" failed to st…'
  ```

- The neighbouring fallback tests already assert the blockquote form,
e.g. `"\n\n> **Warning:** hook says no\n\n"` and the two-line `Stop
says:` message.

This updates the expected string to the blockquoted text; product code
is untouched.

## Validation

- `npx vitest run src/tests/acp-agent.test.ts -t "falls back to a
transcript line without the notices capability"` → 1 passed
- `npx vitest run src/tests/acp-agent.test.ts` → 759 passed | 9 skipped
- `npm run check` → passes
2026-11-08 07:57:15 +00:00
alexwan
47b64c04de fix: start transcript notices on their own line for clients without session.notices (#1362)
## What

When a client does not advertise `session.notices`, a notice falls back
to an `agent_message_chunk` like `**Notice:** …`. Clients render
consecutive chunks as one markdown stream, so the fallback is glued to
Claude's last sentence. In Zed 1.24 a Stop hook's `systemMessage` shows
up as:

```
…推上去。**Notice:** Stop says: token: 3 calls
Stop says: hand off now
```

The fallback is now its own blockquote paragraph, with blank lines on
both sides:

```
…推上去。

> **Notice:** Stop says: token: 3 calls
> hand off now
```

## Behavior

- `noticeOrTranscriptUpdate` wraps the transcript text with
`transcriptBlock`: a leading blank line so it starts on a new line, `> `
on every line, and a trailing blank line so Claude's next chunk does not
continue the quote. This covers every transcript fallback (hook notices,
"Task stopped by user", "Fast mode turned off", "Auto mode
unavailable").
- Claude Code prefixes every line of a hook's `systemMessage` with
`<HookEvent> says: `. The informational fallback keeps that prefix on
the first line only, and only when every line repeats the same prefix
(`collapseHookSpeaker`).
- Clients that advertise `session.notices` get the same `notice` update
as before.
- `Turn.noticeTexts` still records `message.content` unchanged, so the
hook-blocked result de-duplication keeps working.

## Tests

- New unit tests for `transcriptBlock` and `collapseHookSpeaker`, plus
an end-to-end test for a two-line `notice`-level hook message.
- Updated the existing transcript fallback assertions to the blockquote
form.
- `npm run check`, `npm run build` and `npm run test:run` pass (3111
tests).

Co-authored-by: alex <hongchang.wan@longbridge-inc.com>
2026-11-06 22:14:44 +00:00
Jonathan Ellis
1ee2422934 fix: keep autonomous turn tasks open across owed trailing idles (#1455)
When Claude Code starts a new autonomous cycle before the previous
cycle's trailing idle arrives, that late idle completes the new cycle's
AIR async task while Claude is still working. The next output then
creates a second task for the same cycle.

This change completes the autonomous task inside the existing cycle-end
branch, which already checks that `owedTrailingIdles` is zero. An owed
idle is now only consumed by the existing accounting and leaves the
current task open. The task still completes at its own terminal result
or at an unowed idle, so the task and the cycle end on the same
decision.

It adds a scripted SDK-stream regression for two consecutive autonomous
cycles with no running transition between them. The test checks that the
late idle leaves the second task open and that later output stays on the
same task, for a cycle that ends at a result and for one that ends at an
unowed idle. Both cases fail before the fix and pass after it.

Fixes #1453.

Validation, rebased on 0.98.0 (c90f856):
- `npm run build` and `npm run check` pass.
- `npm run test:run`: 3,378 passed, 32 skipped. One unrelated test,
`tools.test.ts` "picks up a rewrite that restored the modification time
of the file", failed once in the full run on a heavily loaded machine.
It passed 3 of 3 runs alone, both on this branch and on `main`, and this
change does not touch `tools.ts` or its tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:11:36 +00:00
Kerekes Dániel
7d1ef76c1d fix: report setup-token logins as logged in (#1376)
Fixes #1295

With a `claude setup-token` login (`CLAUDE_CODE_OAUTH_TOKEN`), `claude
auth status --json` only returns `{"loggedIn": true, "authMethod":
"oauth_token", "apiProvider": "firstParty"}`. `fromCliStatus()` handed
that to `mapAuthFields()`, which has no plan or key fields to go on and
fell back to "Not logged in". Clients then showed a working session as
logged out.

This maps that exact state to `kind: "account"` with the label suggested
in the issue ("Claude subscription", detail "OAuth token"). Everything
else still goes through `mapAuthFields()`.

Added a case to `auth-status.test.ts` that fails without the change.
`npm run check`, `npm run build` and `npm run test:run` pass.
2026-11-06 23:10:04 +01:00
14 changed files with 409 additions and 225 deletions

120
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.4.0",
"@anthropic-ai/claude-agent-sdk": "0.3.318",
"@anthropic-ai/claude-agent-sdk": "0.3.319",
"diff": "9.0.0",
"zod": "4.6.7"
},
@@ -18,7 +18,7 @@
"claude-agent-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "1.4.0",
"@anthropic-ai/sdk": "1.6.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.6",
"@types/node": "26.6.6",
@@ -53,22 +53,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.318.tgz",
"integrity": "sha512-z5WHCUeW52SKQ3rnvI3zt8X5h2QQ5o3Cgg5xarO5Rimfe67f3pOJt2aOh00S8t/nRzqfs+8nCb9o1EoXe+yccA==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.319.tgz",
"integrity": "sha512-7X+sh4uZv/u4OL4I4cQvMr9N3NM6FDiuyk829hFylgbrnNwVyiGElWEDWGuO26zwMIxmgpKdPoDILoFIHh1uwA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.14.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.318",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.318",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.318"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.319",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.319"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -77,9 +77,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.318.tgz",
"integrity": "sha512-AWoXeDXzAMD3RaA21lWuHvCrKd8xmbTTmKjovninn4j07jheoVSMzFbCFjpp4kk5TdhT36JtWPkAxvjNMbcO8w==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.319.tgz",
"integrity": "sha512-rNiAbMbRhgOM2CELsDTIq7q++sFVIZukTkgeswcncGbcX8STIgtf0XaZzKdRXtKK+v9ez+mQn8P4pb2Ka17wGA==",
"cpu": [
"arm64"
],
@@ -90,9 +90,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.318.tgz",
"integrity": "sha512-6CkZ5PCdebSY/LePp16sf/HYRtM6zVuxtn8qYICFS5KUZFsjccJJ9swVsOdHXNHi4SWZcSmIFTwoUbfFCzisRw==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.319.tgz",
"integrity": "sha512-jM730Fl+cFotDnjF9cStIf+Xe57YdfDB3TXaSVVv8uVAXW4xoWFwIj2eDvmsciEEe5UMkKzjVqXu9kR21OrXng==",
"cpu": [
"x64"
],
@@ -103,9 +103,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.318.tgz",
"integrity": "sha512-mBm4su17aMitLBBCQj3bHdejyUDY6z/ormGih+HG24N689RSbdKECjGjkT87tDBIR/4jyoqmuSeHqM1DI8TJlg==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.319.tgz",
"integrity": "sha512-g2tpMsHLMQahI4bk5CIcMHAB/RwzxoPGmL5Q5G5MDir5WTrl71vulWbtLQISUPm/yFPRG97tdzZnCa5Uq1v/dw==",
"cpu": [
"arm64"
],
@@ -119,9 +119,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.318.tgz",
"integrity": "sha512-l4LSYFOrvlYCrUImGfxMTQ0wg+T3s9CBhrBvPF/0oJGKZg+QnxHHU8jUVm9e9/xYmDRTtqFIrr4id/5AfH4XGw==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.319.tgz",
"integrity": "sha512-8KDZQJAS2CEb5aGAvuXkpyHMc9TOM/zYTw2iKmbbVi4im0atw93EtJPapYyp0g/lQI/Xk8jMYdWU6Zlv9EWjLw==",
"cpu": [
"arm64"
],
@@ -135,9 +135,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.318.tgz",
"integrity": "sha512-+mE+ueBD3oRFdaG16rd0h+LMjdeivGxL0pr/+FZK7JeLWVu+yPSw/f/X02b9oK36/DWykiFTu2G6HnJ9/+YO6w==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.319.tgz",
"integrity": "sha512-uBAZA8x0hd5gVifyL5Lo73dVmi9xZeJ6jro2NO0al6yyWaw/6UL9xbsVh9lOhLFOmYIdSbQ2j4zAfnvG/t5F3Q==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.318.tgz",
"integrity": "sha512-SAF3lj/2OzTU84p+OOA13QEKA9+5fYAw0M9E7AJjdnRmVFGqQfbV7Do1i1f9Sj0mIubB3rLTRphGRw8O4HZjgA==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.319.tgz",
"integrity": "sha512-XkKYj3c1jVFjLATrZr4YTsmS5AIb5s2iphF9X670EcVQqDCCGIKKTVNLLg9mg1e7D+2xX8FU0XNlOJatWfpBeA==",
"cpu": [
"x64"
],
@@ -167,9 +167,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.318.tgz",
"integrity": "sha512-19iVSZ4379QbZU1XnGUtfFvhKS1WzS8H1sbU/DbrzqbJ5Kq1eB1drj+obB1fCxTh6m26tRPj0zMJmdeu/jk3Gw==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.319.tgz",
"integrity": "sha512-YoP0lYHJCziENFCotHQq9qd/gaJrwUNkAcnZyVjXxg1OPmtKuyw31aG60s4cWXRDXXGm/m1siNSQaGGfAYBvrw==",
"cpu": [
"arm64"
],
@@ -180,9 +180,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.318.tgz",
"integrity": "sha512-ZdKVWpyVNVt0cXrOf8sUBaxcxLEAP+oGMdQDLBWr/BqOxTWLy7TBWOj1kzw+wVg/9WIkjkgQWeJzGXPWulR1sA==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.319.tgz",
"integrity": "sha512-UC27x+8gfCS2OjNGJWrueP0gLOOKsLEsJ/kg0p6aa8GToZcJYjJ8KeAapHHRYMUAIB6Sarqe49Itm5P1wswCcQ==",
"cpu": [
"x64"
],
@@ -193,9 +193,9 @@
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.4.0.tgz",
"integrity": "sha512-tyuibnhQL57kLOx5oomY4Ld7jOVOPNC5v3IiyRtR3l+Friv66tI4VbuZYF2NMel/T0WMuQMBFOMKDBUbUh8gPA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.6.0.tgz",
"integrity": "sha512-XqiO/46n8S6So8jQ/6/uNCkHntyW6QHHs1gZ+hlwTx1fWDr929g4VmwIXfdMVk7SuBWNdQvAlwyi1G3t6SEIuQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.1",
@@ -215,9 +215,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.13.tgz",
"integrity": "sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -267,9 +267,9 @@
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.3.tgz",
"integrity": "sha512-R76UKbvsqI68DJJVTVyqFKqU98CN4+B+acxNfEA2IFarSzP0SjtKf/3+fFiXzTyv5Fj3PxLkGHe2wnVWWMxobQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -843,9 +843,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.1.0.tgz",
"integrity": "sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==",
"dev": true,
"license": "MIT",
"peer": true
@@ -2270,9 +2270,9 @@
}
},
"node_modules/hono": {
"version": "4.13.16",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.16.tgz",
"integrity": "sha512-AOLOEhvrV+iH92xXCnFr/5+3KjPKcI3xP63XJtX5spAnkznPdHijlIsUTpi7/9EhWqrTCekw5c5zh1K+YsVw7A==",
"version": "4.13.17",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.17.tgz",
"integrity": "sha512-vZIcbkvjOR5hHRWERbGkr8fGfF0GGM1ooYkuH448iO3b2AdVEr3AXZSw4Z3Kn+1L5z0CVoVeyV29VGYmFh05kQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -2408,9 +2408,9 @@
"license": "ISC"
},
"node_modules/jose": {
"version": "6.2.15",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz",
"integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==",
"version": "6.2.16",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.16.tgz",
"integrity": "sha512-4LSJpbuBuPdGrONCdZUy0xnxtLf8eEz+EY0SMXdHQEBU7bgUHDVblU/JAjryv3wID9/LK7/YuwFwq+AQNfMscQ==",
"license": "MIT",
"peer": true,
"funding": {
@@ -3699,17 +3699,17 @@
}
},
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz",
"integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"postcss": "^8.5.32",
"rolldown": "~1.2.16",
"tinyglobby": "^0.2.17"
},
"bin": {

View File

@@ -69,12 +69,12 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "2.4.0",
"@anthropic-ai/claude-agent-sdk": "0.3.318",
"@anthropic-ai/claude-agent-sdk": "0.3.319",
"diff": "9.0.0",
"zod": "4.6.7"
},
"devDependencies": {
"@anthropic-ai/sdk": "1.4.0",
"@anthropic-ai/sdk": "1.6.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.6",
"@types/node": "26.6.6",

View File

@@ -1,13 +1,11 @@
import {
agent as acpAgent,
AgentApp,
AgentContext,
AuthenticateRequest,
AuthMethod,
AvailableCommand,
CancelNotification,
ClientCapabilities,
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
DisableProviderRequest,
@@ -37,7 +35,6 @@ import {
RequestError,
SetProviderRequest,
SetProviderResponse,
RequestPermissionRequest,
RequestPermissionResponse,
ResumeSessionRequest,
ResumeSessionResponse,
@@ -106,6 +103,10 @@ import {
toGoalSnapshot,
} from "./goal-extension.js";
import { blobResourceToClaude } from "./blob-resources.js";
import { ChangedMetaClient, ClientConnection } from "./acp-client.js";
import type { AcpClient, Logger } from "./acp-client.js";
export type { AcpClient, Logger } from "./acp-client.js";
import { promptSuggestionsMeta, promptSuggestionText } from "./prompt-suggestions.js";
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
import {
@@ -284,6 +285,7 @@ import {
import { IdleCompaction } from "./idle-compaction.js";
import {
clientSupportsNotices,
collapseHookSpeaker,
MAX_NOTICE_TITLE_LENGTH,
normalizeNoticeText,
noticeOrTranscriptUpdate,
@@ -317,7 +319,6 @@ import {
withRunningTerminal,
withRunningToolCall,
} from "./tool-calls/background.js";
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
import {
@@ -426,16 +427,6 @@ function isEmptyUserInterruptionDiagnostic(
);
}
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
warn?: (...args: any[]) => void;
}
type AccumulatedUsage = {
inputTokens: number;
outputTokens: number;
@@ -2408,137 +2399,6 @@ export function isReplayHiddenMetaMessage(message: unknown): boolean {
);
}
/**
* Client-facing surface the agent calls back into. This is the subset of ACP
* client methods the agent actually uses, expressed as a narrow interface so
* tests can supply lightweight mocks. In production it is backed by
* {@link ClientConnection} over the SDK's typed `AgentContext`.
*/
export interface AcpClient {
sessionUpdate(params: AcpSessionNotification): Promise<void>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* permission request so the client can dismiss its prompt, and rejects the
* call at once: a client that ignores the cancel never answers. */
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse>;
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* elicitation so the client can dismiss its prompt, and rejects the call
* at once, as {@link requestPermission} does. */
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
}
/**
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
* valid for the entire connection lifetime, so it is captured once at
* construction.
*/
class ClientConnection implements AcpClient {
constructor(private readonly ctx: AgentContext) {}
sessionUpdate(params: AcpSessionNotification): Promise<void> {
return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
}
requestPermission(
params: RequestPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.ctx.request(methods.client.session.requestPermission, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.ctx.request(methods.client.fs.readTextFile, params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.ctx.request(methods.client.fs.writeTextFile, params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.ctx.request(methods.client.elicitation.create, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(methods.client.elicitation.complete, params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.ctx.notify(method, params);
}
}
/**
* The client that the agent talks to. For an AIR client, every session update
* passes the {@link ChangedMetaFilter} last, after the native subagent routing,
* so a `tool_call_update` carries only the `_meta` keys that changed. ACP
* merges only the top-level tool call fields, not the `_meta` keys. So another
* client gets the full `_meta` on each update.
*/
class ChangedMetaClient implements AcpClient {
private readonly filter = new ChangedMetaFilter();
constructor(
private readonly inner: AcpClient,
private readonly airClient: () => boolean,
) {}
async sessionUpdate(params: AcpSessionNotification): Promise<void> {
if (!this.airClient()) return this.inner.sessionUpdate(params);
const update = this.filter.apply(params.update as SessionNotification["update"]);
if (update) await this.inner.sessionUpdate({ ...params, update } as AcpSessionNotification);
}
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.inner.requestPermission(params, signal);
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.inner.readTextFile(params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.inner.writeTextFile(params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.inner.createElicitation(params, signal);
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.inner.completeElicitation(params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.inner.extNotification(method, params);
}
}
function raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const cleanup = () => signal.removeEventListener("abort", onAbort);
@@ -6414,8 +6274,6 @@ export class ClaudeAcpAgent {
!firstUnsettledQueuedTurn()
) {
await asyncTasks.autonomousTurnStarted();
} else if (message.state === "idle" && asyncTasks.autonomousTurnOpen) {
await asyncTasks.autonomousTurnEnded("completed");
}
if (
message.state === "running" &&
@@ -6447,6 +6305,9 @@ export class ClaudeAcpAgent {
// here. An owed idle belongs to an earlier result: it can lag
// past the cycle's first output.
if (session.owedTrailingIdles === 0) {
if (asyncTasks.autonomousTurnOpen) {
await asyncTasks.autonomousTurnEnded("completed");
}
await closeCycleToolCalls();
this.endAutonomousCycle(params.sessionId, {
outcome: { stopReason: session.cancelled ? "cancelled" : "end_turn" },
@@ -6719,10 +6580,11 @@ export class ClaudeAcpAgent {
}
}
const severity = message.level === "warning" ? "warning" : "info";
const transcriptContent = collapseHookSpeaker(message.content);
const transcriptText =
message.level === "info"
? message.content
: `**${sentenceCase(message.level)}:** ${message.content}`;
? transcriptContent
: `**${sentenceCase(message.level)}:** ${transcriptContent}`;
await sendUpdate({
sessionId: params.sessionId,
update: noticeOrTranscriptUpdate(

158
src/acp-client.ts Normal file
View File

@@ -0,0 +1,158 @@
import {
AgentContext,
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
methods,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk/unstable";
import { AcpSessionNotification, asSdkSessionNotification } from "./acp-subagents.js";
import { type AcpPermissionRequest } from "./permissions/presentation.js";
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
warn?: (...args: any[]) => void;
}
/**
* Client-facing surface the agent calls back into. This is the subset of ACP
* client methods the agent actually uses, expressed as a narrow interface so
* tests can supply lightweight mocks. In production it is backed by
* {@link ClientConnection} over the SDK's typed `AgentContext`.
*/
export interface AcpClient {
sessionUpdate(params: AcpSessionNotification): Promise<void>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* permission request so the client can dismiss its prompt, and rejects the
* call at once: a client that ignores the cancel never answers. */
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse>;
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* elicitation so the client can dismiss its prompt, and rejects the call
* at once, as {@link requestPermission} does. */
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
}
/**
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
* valid for the entire connection lifetime, so it is captured once at
* construction.
*/
export class ClientConnection implements AcpClient {
constructor(private readonly ctx: AgentContext) {}
sessionUpdate(params: AcpSessionNotification): Promise<void> {
return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
}
requestPermission(
params: RequestPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.ctx.request(methods.client.session.requestPermission, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.ctx.request(methods.client.fs.readTextFile, params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.ctx.request(methods.client.fs.writeTextFile, params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.ctx.request(methods.client.elicitation.create, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(methods.client.elicitation.complete, params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.ctx.notify(method, params);
}
}
/**
* The client that the agent talks to. For an AIR client, every session update
* passes the {@link ChangedMetaFilter} last, after the native subagent routing,
* so a `tool_call_update` carries only the `_meta` keys that changed. ACP
* merges only the top-level tool call fields, not the `_meta` keys. So another
* client gets the full `_meta` on each update.
*/
export class ChangedMetaClient implements AcpClient {
private readonly filter = new ChangedMetaFilter();
constructor(
private readonly inner: AcpClient,
private readonly airClient: () => boolean,
) {}
async sessionUpdate(params: AcpSessionNotification): Promise<void> {
if (!this.airClient()) return this.inner.sessionUpdate(params);
const update = this.filter.apply(params.update as SessionNotification["update"]);
if (update) await this.inner.sessionUpdate({ ...params, update } as AcpSessionNotification);
}
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.inner.requestPermission(params, signal);
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.inner.readTextFile(params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.inner.writeTextFile(params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.inner.createElicitation(params, signal);
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.inner.completeElicitation(params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.inner.extNotification(method, params);
}
}

View File

@@ -210,6 +210,15 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
if (!externalBackend && !status.loggedIn && !status.apiKeySource) {
return notLoggedInAuthStatus();
}
if (
status.loggedIn &&
status.authMethod === "oauth_token" &&
status.apiProvider === "firstParty" &&
!status.apiKeySource &&
!status.subscriptionType
) {
return { kind: "account", label: "Claude subscription", detail: "OAuth token" };
}
return mapAuthFields({
apiProvider: status.apiProvider,
subscriptionType: status.subscriptionType,

View File

@@ -303,7 +303,7 @@ export function applyAskElicitationResponse(
questions: AskUserQuestion[],
options: { reportUnanswered?: boolean } = {},
): AskUserQuestionOutcome {
// Typed against the output schema: as of SDK 0.3.318 only it lists
// Typed against the output schema: as of SDK 0.3.319 only it lists
// `unanswered`, though the tool reads the same annotations from its input.
const annotations: NonNullable<AskUserQuestionOutput["annotations"]> = {};
const withAnnotations = (answers: AskUserQuestionOutput["answers"]) => ({

View File

@@ -50,11 +50,35 @@ export function noticeOrTranscriptUpdate(
if (supportsNotices) return noticeUpdate(notice);
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: transcriptText },
content: { type: "text", text: transcriptBlock(transcriptText) },
...(transcriptMeta ? { _meta: transcriptMeta } : {}),
};
}
/** Clients render consecutive agent_message_chunks as one markdown stream, so
* a bare `**Notice:** …` chunk lands mid-sentence: "…all done.**Notice:** Stop
* says: …". Put it in its own blockquote paragraph, blank lines on both sides,
* so it starts on a new line and Claude's next chunk does not continue the
* quote. */
export function transcriptBlock(text: string): string {
const lines = text.trim().split(/\r?\n/);
return `\n\n${lines.map((line) => (line ? `> ${line}` : ">")).join("\n")}\n\n`;
}
const HOOK_SPEAKER = /^[^\n:]{1,80} says: /;
/** Claude Code prefixes every line of a hook's `systemMessage` with
* "<HookEvent> says: ", so a two-line message reads "Stop says: a\nStop says:
* b". Keep the prefix on the first line only when every line repeats it. */
export function collapseHookSpeaker(text: string): string {
const lines = text.split(/\r?\n/);
const prefix = lines[0].match(HOOK_SPEAKER)?.[0];
if (!prefix || lines.length < 2 || !lines.every((line) => line.startsWith(prefix))) {
return text;
}
return [lines[0], ...lines.slice(1).map((line) => line.slice(prefix.length))].join("\n");
}
/** Fragments that read on after a bold label stand alone as a notice
* description, so start them with a capital. */
export function sentenceCase(text: string): string {

View File

@@ -25,7 +25,7 @@ export type UltracodeSettingsReport = {
};
// `Query.getSettings()` exists at runtime (sdk.mjs) but is missing from the
// SDK's type declarations as of 0.3.318.
// SDK's type declarations as of 0.3.319.
type GetSettingsQuery = Query & {
getSettings?: () => Promise<{
applied?: { ultracodeAvailable?: unknown; ultracodeRequested?: unknown };

View File

@@ -13858,7 +13858,7 @@ describe("MCP server startup failures", () => {
);
expect(lines).toHaveLength(1);
expect(lines[0].content.text).toBe(
'**MCP server "client-fs" failed to start:** Its tools are unavailable in this session. Run /mcp for the error.',
'\n\n> **MCP server "client-fs" failed to start:** Its tools are unavailable in this session. Run /mcp for the error.\n\n',
);
});
});
@@ -18850,7 +18850,7 @@ describe("assembled assistant text fallback", () => {
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "1+2" }] });
const chunks = updates.filter(({ update }) => update.sessionUpdate === "agent_message_chunk");
expect(messageChunkTexts(updates)).toEqual(["**Warning:** hook says no"]);
expect(messageChunkTexts(updates)).toEqual(["\n\n> **Warning:** hook says no\n\n"]);
expect(chunks[0]?.update).toMatchObject({
_meta: { claudeCode: { kind: "informational", level: "warning" } },
});
@@ -18905,12 +18905,33 @@ describe("assembled assistant text fallback", () => {
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "1+2" }] });
const chunks = updates.filter(({ update }) => update.sessionUpdate === "agent_message_chunk");
expect(messageChunkTexts(updates)).toEqual(["plain hook detail"]);
expect(messageChunkTexts(updates)).toEqual(["\n\n> plain hook detail\n\n"]);
expect(chunks[0]?.update).toMatchObject({
_meta: { claudeCode: { kind: "informational", level: "info" } },
});
});
it("renders a multi-line hook notice as one quote with a single speaker prefix", async () => {
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
{
type: "system",
subtype: "informational",
content: "Stop says: token: 3 calls\nStop says: hand off now",
level: "notice",
session_id: "test-session",
},
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "1+2" }] });
expect(messageChunkTexts(updates)).toEqual([
"\n\n> **Notice:** Stop says: token: 3 calls\n> hand off now\n\n",
]);
});
it("delivers an informational frame as a notice and still owns the result text", async () => {
const { agent, updates } = createMockAgentWithCapture();
await initializeClient(agent, { session: { notices: {} } });
@@ -25953,6 +25974,82 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
]);
});
// Issue #1453: a previous cycle's trailing idle completed the next turn task.
it.each(["result", "idle"] as const)(
"keeps the next turn task open across an owed idle until its own %s",
async (end) => {
const { agent, asyncTaskUpdates } = await airAgent();
let wake = () => {};
const woken = new Promise<void>((resolve) => (wake = resolve));
let finish = () => {};
const finished = new Promise<void>((resolve) => (finish = resolve));
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
yield userEcho((await iter.next()).value);
yield running();
yield resultMessage();
yield idle();
await woken;
yield running();
yield assistantText("first autonomous cycle");
yield resultMessage({ origin: { kind: "task-notification" } });
// The CLI stays running: the next output opens a new turn task.
yield assistantText("next autonomous cycle");
yield idle(); // The previous autonomous result's late trailer.
await finished;
yield assistantText("still working in the next cycle");
if (end === "result") {
yield resultMessage({ origin: { kind: "task-notification" } });
}
yield idle();
}
return messageGenerator();
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
wake();
try {
await waitFor(
() =>
asyncTaskUpdates().filter((u) => u.sessionUpdate === "async_task_spawned").length ===
2 && agent.sessions["test-session"].owedTrailingIdles === 0,
);
const [first, firstEnd, next] = asyncTaskUpdates();
expect(asyncTaskUpdates()).toEqual([
first,
{
sessionUpdate: "async_task_state_update",
asyncTaskId: first.asyncTaskId,
state: "completed",
},
next,
]);
expect(firstEnd.state).toBe("completed");
expect(next.sessionUpdate).toBe("async_task_spawned");
expect(next.asyncTaskId).not.toBe(first.asyncTaskId);
} finally {
finish();
await agent.sessions["test-session"]?.consumer;
}
const [first, , next] = asyncTaskUpdates();
expect(asyncTaskUpdates()).toEqual([
first,
{
sessionUpdate: "async_task_state_update",
asyncTaskId: first.asyncTaskId,
state: "completed",
},
next,
{
sessionUpdate: "async_task_state_update",
asyncTaskId: next.asyncTaskId,
state: "completed",
},
]);
},
);
describe("started by a scheduler fire", () => {
const lifecycleStarted = (fire: { wakeup_source?: string; scheduled_for?: number }) =>
({

View File

@@ -168,7 +168,10 @@ describe("AsyncTaskRuntime", () => {
expect(published[1]?.update).not.toHaveProperty("summary");
expect(published.at(-1)?.update).toMatchObject({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "**Task stopped by user:** Build generated assets." },
content: {
type: "text",
text: "\n\n> **Task stopped by user:** Build generated assets.\n\n",
},
});
});
@@ -234,7 +237,7 @@ describe("AsyncTaskRuntime", () => {
]);
expect(published.at(-1)?.update).toMatchObject({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "**Task stopped by user:** npm run build." },
content: { type: "text", text: "\n\n> **Task stopped by user:** npm run build.\n\n" },
});
});
@@ -264,7 +267,7 @@ describe("AsyncTaskRuntime", () => {
});
expect(published.at(-1)?.update).toMatchObject({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "**Task stopped by user:** npm run build." },
content: { type: "text", text: "\n\n> **Task stopped by user:** npm run build.\n\n" },
});
});

View File

@@ -306,6 +306,14 @@ describe("auth status mappers", () => {
});
});
it("maps a setup-token CLI login without profile fields to an account", () => {
expect(
fromCliStatus(
JSON.stringify({ loggedIn: true, authMethod: "oauth_token", apiProvider: "firstParty" }),
),
).toEqual({ kind: "account", label: "Claude subscription", detail: "OAuth token" });
});
it("lets a third-party backend outrank the logged-out flag", () => {
// `loggedIn` tracks the claude.ai login only; on Bedrock the credentials
// are external and the agent is authenticated all the same.

View File

@@ -413,7 +413,7 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "**Fast mode turned off:** requires extra usage to be enabled for this account.",
text: "\n\n> **Fast mode turned off:** requires extra usage to be enabled for this account.\n\n",
},
});
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });

View File

@@ -112,7 +112,7 @@ describe("session mode", () => {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "**Auto mode unavailable:** the selected model does not support Auto mode; using Accept edits instead.",
text: "\n\n> **Auto mode unavailable:** the selected model does not support Auto mode; using Accept edits instead.\n\n",
},
});
});

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
clientSupportsNotices,
collapseHookSpeaker,
MAX_NOTICE_TITLE_LENGTH,
normalizeNoticeText,
noticeOrTranscriptUpdate,
@@ -8,6 +9,7 @@ import {
noticeTranscriptText,
sentenceCase,
splitNoticeText,
transcriptBlock,
} from "../session-notices.js";
describe("session notices", () => {
@@ -30,21 +32,42 @@ describe("session notices", () => {
expect(noticeTranscriptText({ severity: "info", title: "Done" })).toBe("**Done**");
expect(noticeOrTranscriptUpdate(notice, false)).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "**Fast mode turned off:** Why." },
content: { type: "text", text: "\n\n> **Fast mode turned off:** Why.\n\n" },
});
expect(noticeOrTranscriptUpdate(notice, false, "custom line")).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "custom line" },
content: { type: "text", text: "\n\n> custom line\n\n" },
});
const meta = { claudeCode: { kind: "informational", level: "warning" } };
expect(noticeOrTranscriptUpdate(notice, false, "custom line", meta)).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "custom line" },
content: { type: "text", text: "\n\n> custom line\n\n" },
_meta: meta,
});
expect(noticeOrTranscriptUpdate(notice, true, "custom line", meta)).not.toHaveProperty("_meta");
});
it("puts a transcript notice in its own blockquote paragraph", () => {
// Without blank lines the chunk renders glued to Claude's last sentence:
// "…all done.**Notice:** Stop says: …".
expect(transcriptBlock("**Notice:** one line")).toBe("\n\n> **Notice:** one line\n\n");
expect(transcriptBlock("**Notice:** first\nsecond\n\nfourth\n")).toBe(
"\n\n> **Notice:** first\n> second\n>\n> fourth\n\n",
);
});
it("keeps a hook's repeated speaker prefix on the first line only", () => {
expect(collapseHookSpeaker("Stop says: token: 3 calls\nStop says: hand off now")).toBe(
"Stop says: token: 3 calls\nhand off now",
);
expect(collapseHookSpeaker("Stop says: one line")).toBe("Stop says: one line");
// Left alone unless every line repeats the same prefix.
expect(collapseHookSpeaker("Stop says: a\nsomething else")).toBe(
"Stop says: a\nsomething else",
);
expect(collapseHookSpeaker("no prefix\nStop says: b")).toBe("no prefix\nStop says: b");
});
it("sends a notice update when the client can present one", () => {
expect(
noticeOrTranscriptUpdate({ severity: "info", title: "Task stopped by user" }, true),