Compare commits

..

3 Commits

Author SHA1 Message Date
Ben Brandt
affc731e3a docs: clarify stream-id tracking and liveStreamed dedup assumptions
Add comments explaining (1) why the streamed-id tracking runs for every
stream_event rather than only parent_tool_use_id === null, and that the
single currentStreamingMessageId field is safe given serialized events,
and (2) that the liveStreamed drop is all-or-nothing per message id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:03:50 +02:00
cuiyiming.leo95
63b708176e fix: dedup fallback text/thinking per content block, not per message id
The fallback added in dc640e2 keyed its dedup on the assistant message
id: once a message id emitted any text/thinking block, the id was added
to `textStreamedMessageIds` and every later block under that id was
filtered out. That breaks when an OpenAI-compatible gateway (observed
with gpt-5.4) splits a single assistant response into several `assistant`
messages that share one id but each carry a different block — typically
an empty `thinking` block first, then the real `text` block. The empty
thinking block flagged the id, so the actual answer arriving in a later
same-id message was dropped: the client saw the fallback log fire but
received an empty turn.

Track the assembled blocks the fallback already emitted this turn
(`fallbackEmittedBlockKeys`, keyed on id + block type + block content)
instead of flagging the whole message id. Distinct blocks that share an
id are all forwarded, while identical re-deliveries (an incremental block
followed by a consolidated message, or the same message delivered twice)
still dedup. `textStreamedMessageIds` returns to its original meaning —
"was this streamed live via deltas" — and is no longer written by the
fallback. The new set is turn-scoped and cleared alongside it.

Tests cover split-block delivery (empty thinking then text under one id),
multiple distinct text blocks sharing an id, and the
incremental-then-consolidated dedup case.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 23:57:26 -07:00
cuiyiming.leo95
dc640e2dcf fix: fall back to assembled assistant text when no stream deltas arrive
The `assistant` case in the prompt loop unconditionally filtered out
`text` and `thinking` content blocks on the assumption that those had
already been pushed to the client via `stream_event` →
`content_block_delta` notifications. That assumption holds when the
underlying SDK / model proxy emits Anthropic-style streaming deltas, but
it breaks when the model is fronted by a proxy that returns the second
model call (the one that consumes a tool_result) as a single non-
streamed assistant message. In that case `content_block_delta` for the
final text is never produced, the assembled `text` block is filtered
out, and the ACP client receives `stopReason: "end_turn"` with no
`agent_message_chunk` notifications — so the user sees the tool's
progress but an empty final answer.

Track which assistant message ids have actually produced streamed
text/thinking deltas (`textStreamedMessageIds` on `Session`, populated
from `message_start` + `content_block_delta` in the `stream_event`
case). In the `assistant` case, keep the existing filter when the id is
in that set and fall back to forwarding the assembled `text` / `thinking`
blocks as `agent_message_chunk` / `agent_thought_chunk` when it isn't.
The fallback emits exactly once per assistant message id, mirroring the
dedupe pattern already used by `toolUseCache` for tool_use blocks.

Tests cover both branches: a session whose assistant message arrives
with no preceding `content_block_delta` now produces exactly one text
chunk, and a session that did stream the same text via deltas does not
re-emit it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 18:51:35 -07:00
8 changed files with 691 additions and 288 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.39.0"
".": "0.37.0"
}

View File

@@ -1,25 +1,5 @@
# Changelog
## [0.39.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.38.0...v0.39.0) (2026-05-29)
### Features
* **deps:** update Claude Agent SDK to 0.3.156 ([#722](https://github.com/agentclientprotocol/claude-agent-acp/issues/722)) ([3fb6db4](https://github.com/agentclientprotocol/claude-agent-acp/commit/3fb6db4e5ee5bd1f431cf5d2ce5331ce983f9597))
## [0.38.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.37.0...v0.38.0) (2026-05-28)
### Features
* **deps:** bump to @anthropic-ai/claude-agent-sdk 0.3.154 (Opus 4.8) ([#716](https://github.com/agentclientprotocol/claude-agent-acp/issues/716)) ([a172885](https://github.com/agentclientprotocol/claude-agent-acp/commit/a172885c80fc32c108a8501268a543641088f543))
* Support Opus 4.8 ([#718](https://github.com/agentclientprotocol/claude-agent-acp/issues/718)) ([98b54a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/98b54a02c267cdef619becceccf6ef519fbf8f6f))
### Bug Fixes
* Remove hide Claude auth flag handling ([#707](https://github.com/agentclientprotocol/claude-agent-acp/issues/707)) ([7ed1daf](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ed1daf7b42088ad149e9bf742f764752b82d093))
## [0.37.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.36.1...v0.37.0) (2026-05-21)

280
package-lock.json generated
View File

@@ -1,27 +1,27 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.39.0",
"version": "0.37.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.39.0",
"version": "0.37.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.22.1",
"@anthropic-ai/claude-agent-sdk": "0.3.156",
"@anthropic-ai/claude-agent-sdk": "0.3.146",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
"claude-agent-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.100.0",
"@anthropic-ai/sdk": "0.97.1",
"@eslint/js": "10.0.1",
"@types/node": "25.9.1",
"@typescript-eslint/eslint-plugin": "8.60.0",
"@typescript-eslint/parser": "8.60.0",
"@typescript-eslint/eslint-plugin": "8.59.4",
"@typescript-eslint/parser": "8.59.4",
"eslint": "10.4.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.6.0",
@@ -41,22 +41,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.156",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.156.tgz",
"integrity": "sha512-6nM/Dj+VMds52UXJ2YaV4IKhYamlUqN0HtdDrFzYz5lvPMpDS935qD8YZDAUpy+ltdoD6PJMd1V/CKFY3/oWCQ==",
"version": "0.3.146",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.146.tgz",
"integrity": "sha512-hK9/Ng+hOyexUemTxdIUsSWJ9o2LFi2YNWzHwz8/YMCohUYOnFMZkBiENvUAb0WIc5hieOyBZrOIlg5OewuJMg==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.156",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.156",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.156",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.156",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.156",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.156",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.156",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.156"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.146",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.146",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.146",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.146",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.146",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.146",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.146",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.146"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -65,9 +65,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.156",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.156.tgz",
"integrity": "sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA==",
"version": "0.3.146",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.146.tgz",
"integrity": "sha512-0IIvlEaenq2CRSVx5Bo5BaCtHQXS87GancM35WKEYveGVLn6DI+5G7ikYuTE4AKRPkMnogFtY4BJt6LulWGj+A==",
"cpu": [
"arm64"
],
@@ -78,9 +78,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.156",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.156.tgz",
"integrity": "sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g==",
"version": "0.3.146",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.146.tgz",
"integrity": "sha512-Dk5xJ03Ff1JXbMRP1t2wc/TyfY6xF/2Ysp31wMhFPjoNiKSPHMWaIg242+T3CHdxLWmJ8plWHL1HL5cyZ/LCkw==",
"cpu": [
"x64"
],
@@ -91,15 +91,12 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.156",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.156.tgz",
"integrity": "sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ==",
"version": "0.3.146",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.146.tgz",
"integrity": "sha512-mzBXDDWWBAC/vDtAYpO1G/dq5QvJtYSPXsqcb+sNdcDhiuf4IYnYp7ytRncYlsUNDkLmX6Gk2jkWAHUUA2Lozg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -107,15 +104,12 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.156",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.156.tgz",
"integrity": "sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw==",
"version": "0.3.146",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.146.tgz",
"integrity": "sha512-QlCid0ucdrmhUAOewfQjaofN2wlokWcfFTxSFePTSj1umk35JO7TDFP700F7jU49r1fPWIdvJpPwWGyB0DeFPA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -123,15 +117,12 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.156",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.156.tgz",
"integrity": "sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg==",
"version": "0.3.146",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.146.tgz",
"integrity": "sha512-B2baXU1tCBT5CVlD7jJMKjpC4xdO45NUIWpqImmwuOfKvlM/PITjyTXyTY662mGZf1dBmdqBBsqirwFH/jhi8Q==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -139,15 +130,12 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.156",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.156.tgz",
"integrity": "sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA==",
"version": "0.3.146",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.146.tgz",
"integrity": "sha512-E3coK1ThQT08KIX80RLcsq7DWXFllCKOzoOe32it/bdtY56TBgPY9xemwXhIJ+cVBHTI9/MpBSIlKBcFCt+yQA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -155,9 +143,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.156",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.156.tgz",
"integrity": "sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw==",
"version": "0.3.146",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.146.tgz",
"integrity": "sha512-CIwQxGX2r/yWpjCJ6ahB3smKXhghWgGTxL98+LGW52TUwqTiBnlNrH9DPqqgv1/+Hyquw6xfLrKU+StyfMgiLw==",
"cpu": [
"arm64"
],
@@ -168,9 +156,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.156",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.156.tgz",
"integrity": "sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw==",
"version": "0.3.146",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.146.tgz",
"integrity": "sha512-qmxrsyaqA8s4HShqJls7ZCRjdoqN66Jo/hbjQNB3uHepD8tEO1iD19aPV4+osdLT7feMkhDBfLT07Q30R2NB5w==",
"cpu": [
"x64"
],
@@ -181,9 +169,9 @@
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.100.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.0.tgz",
"integrity": "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ==",
"version": "0.97.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.97.1.tgz",
"integrity": "sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
@@ -202,9 +190,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -642,9 +630,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -662,9 +647,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -682,9 +664,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -702,9 +681,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -722,9 +698,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -742,9 +715,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -933,17 +903,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
"integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.60.0",
"@typescript-eslint/type-utils": "8.60.0",
"@typescript-eslint/utils": "8.60.0",
"@typescript-eslint/visitor-keys": "8.60.0",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/type-utils": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -956,22 +926,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.60.0",
"@typescript-eslint/parser": "8.59.4",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz",
"integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.60.0",
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/typescript-estree": "8.60.0",
"@typescript-eslint/visitor-keys": "8.60.0",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -987,14 +957,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz",
"integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.60.0",
"@typescript-eslint/types": "^8.60.0",
"@typescript-eslint/tsconfig-utils": "^8.59.4",
"@typescript-eslint/types": "^8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -1009,14 +979,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz",
"integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/visitor-keys": "8.60.0"
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1027,9 +997,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz",
"integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1044,15 +1014,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz",
"integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/typescript-estree": "8.60.0",
"@typescript-eslint/utils": "8.60.0",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -1069,9 +1039,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz",
"integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1083,16 +1053,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz",
"integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.60.0",
"@typescript-eslint/tsconfig-utils": "8.60.0",
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/visitor-keys": "8.60.0",
"@typescript-eslint/project-service": "8.59.4",
"@typescript-eslint/tsconfig-utils": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -1111,16 +1081,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz",
"integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.60.0",
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/typescript-estree": "8.60.0"
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1135,13 +1105,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz",
"integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/types": "8.59.4",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -1683,9 +1653,9 @@
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -1977,9 +1947,9 @@
}
},
"node_modules/eventsource-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
"integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -2330,9 +2300,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -2343,9 +2313,9 @@
}
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"version": "4.12.21",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
"integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -2691,9 +2661,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2715,9 +2682,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2739,9 +2703,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2763,9 +2724,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3346,9 +3304,9 @@
"peer": true
},
"node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -3568,9 +3526,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.39.0",
"version": "0.37.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -61,15 +61,15 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.22.1",
"@anthropic-ai/claude-agent-sdk": "0.3.156",
"@anthropic-ai/claude-agent-sdk": "0.3.146",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.100.0",
"@anthropic-ai/sdk": "0.97.1",
"@eslint/js": "10.0.1",
"@types/node": "25.9.1",
"@typescript-eslint/eslint-plugin": "8.60.0",
"@typescript-eslint/parser": "8.60.0",
"@typescript-eslint/eslint-plugin": "8.59.4",
"@typescript-eslint/parser": "8.59.4",
"eslint": "10.4.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.6.0",

View File

@@ -165,6 +165,29 @@ type Session = {
/** Accumulated task list for the session, keyed by task ID. Task IDs are
* per-session, so this state must not be shared across sessions. */
taskState: TaskState;
/** Assistant message ids whose text/thinking content was already emitted as
* `agent_message_chunk` / `agent_thought_chunk` via `stream_event` partial
* messages. Used by the top-level `assistant` case to decide whether to
* skip text/thinking blocks (the default, when streaming worked) or fall
* back to emitting the assembled blocks (when the underlying SDK / model
* proxy never produced `content_block_delta` for them). */
textStreamedMessageIds: Set<string>;
/** Keys of assembled text/thinking blocks the fallback path has already
* forwarded this turn, used to dedup re-delivered blocks without dropping
* genuinely new ones. A single assistant message id is frequently
* delivered as several `assistant` messages, each carrying a *different*
* subset of content blocks (e.g. an empty `thinking` block, then the real
* `text` block); deduping at message-id granularity would emit the first
* block, flag the id, then silently drop the later text. Keying on the
* block content instead lets distinct blocks that share an id all through
* while still collapsing identical re-deliveries (incremental + consolidated
* messages, or the same message delivered twice). */
fallbackEmittedBlockKeys: Set<string>;
/** Id of the assistant message currently being streamed via `stream_event`
* partial messages, captured at `message_start` so subsequent
* `content_block_delta` events can record their parent into
* `textStreamedMessageIds`. */
currentStreamingMessageId?: string;
};
/** Compute a stable fingerprint of the session-defining params so we can
@@ -743,6 +766,14 @@ export class ClaudeAcpAgent implements Agent {
cachedWriteTokens: 0,
};
// Assistant message ids and fallback block keys are turn-scoped — clear
// before a new turn so the sets don't grow unboundedly over a long-lived
// session, and so that an id reused across turns (uncommon but possible)
// is re-evaluated.
session.textStreamedMessageIds.clear();
session.fallbackEmittedBlockKeys.clear();
session.currentStreamingMessageId = undefined;
let lastAssistantTotalUsage: number | null = null;
let lastAssistantUsage: UsageSnapshot | null = null;
let lastAssistantModel: string | null = null;
@@ -753,13 +784,6 @@ export class ClaudeAcpAgent implements Agent {
// forward it to clients as structured `data`, sparing them from
// pattern-matching on the human-readable message text.
let lastAssistantError: SDKAssistantMessageError | undefined;
// Tracks whether we're inside a compaction. The SDK emits the terminal
// `status` (compact_result success/failed) twice for a single failed
// compaction, and the two messages are indistinguishable — so we report the
// outcome only while a compaction is in progress, then clear this. A fresh
// `compacting` status sets it again, so every distinct compaction (e.g.
// repeated auto-compactions in a long turn) is still shown.
let compactionInProgress = false;
const userMessage = promptToClaude(params);
@@ -819,7 +843,6 @@ export class ClaudeAcpAgent implements Agent {
break;
case "status": {
if (message.status === "compacting") {
compactionInProgress = true;
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
@@ -827,28 +850,6 @@ export class ClaudeAcpAgent implements Agent {
content: { type: "text", text: "Compacting..." },
},
});
} else if (message.compact_result === "success" && compactionInProgress) {
// The SDK signals manual `/compact` completion with a status
// message carrying `compact_result`, not the `compact_boundary`
// message (which only fires when there's content to compact).
compactionInProgress = false;
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "\n\nCompacting completed." },
},
});
} else if (message.compact_result === "failed" && compactionInProgress) {
compactionInProgress = false;
const reason = message.compact_error ? `: ${message.compact_error}` : ".";
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: `\n\nCompacting failed${reason}` },
},
});
}
break;
}
@@ -864,10 +865,6 @@ export class ClaudeAcpAgent implements Agent {
// The alternative (no update) leaves the client showing e.g.
// "944k/1m" right after the user sees "Compacting completed",
// which is confusing and wrong.
//
// The "Compacting completed." text is emitted from the `status`
// handler (keyed on `compact_result`), not here, so the failure
// path gets a message too.
lastAssistantTotalUsage = 0;
lastAssistantUsage = null;
await this.client.sessionUpdate({
@@ -878,6 +875,13 @@ export class ClaudeAcpAgent implements Agent {
size: session.contextWindowSize,
},
});
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "\n\nCompacting completed." },
},
});
break;
}
case "local_command_output": {
@@ -953,7 +957,6 @@ export class ClaudeAcpAgent implements Agent {
case "api_retry":
case "mirror_error":
case "permission_denied":
case "thinking_tokens":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
default:
@@ -1084,6 +1087,31 @@ export class ClaudeAcpAgent implements Agent {
break;
}
case "stream_event": {
// Track which assistant message ids have produced streamed text /
// thinking deltas so the `assistant` case below can fall back to
// emitting the assembled content when streaming didn't (e.g. when
// the SDK is talking to a model proxy that never produces
// `content_block_delta` events for the second model call in a
// tool-use loop).
//
// This runs for every stream_event (not gated on
// `parent_tool_use_id === null` like the usage block below) because
// subagent assistant messages flow through the same content filter.
// `currentStreamingMessageId` is a single field, which is safe
// because the SDK serializes the event stream and `message_start`
// always precedes its own `content_block_delta`s, so the captured
// id is always the parent of the deltas that follow.
if (message.event.type === "message_start") {
session.currentStreamingMessageId = message.event.message.id;
} else if (
message.event.type === "content_block_delta" &&
(message.event.delta.type === "text_delta" ||
message.event.delta.type === "thinking_delta") &&
session.currentStreamingMessageId
) {
session.textStreamedMessageIds.add(session.currentStreamingMessageId);
}
if (
message.parent_tool_use_id === null &&
(message.event.type === "message_start" || message.event.type === "message_delta")
@@ -1199,7 +1227,6 @@ export class ClaudeAcpAgent implements Agent {
// payloads (e.g. /compact's malformed output) strip to null and are
// skipped. Mirrors the replay path at replaySessionHistory.
if (
message.message.role !== "system" &&
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stdout>")
) {
@@ -1244,9 +1271,6 @@ export class ClaudeAcpAgent implements Agent {
) {
break;
}
if (message.message.role === "system") {
break;
}
if (
message.type === "assistant" &&
@@ -1259,14 +1283,77 @@ export class ClaudeAcpAgent implements Agent {
throw RequestError.authRequired();
}
// Text and thinking blocks are normally emitted from the
// `stream_event` case above as `content_block_delta` arrives,
// so the assembled message replays them here. We skip them in
// that case to avoid duplication. However, if streaming never
// produced any text/thinking delta for this assistant message
// (e.g. because the underlying model proxy returned the response
// as a single non-streamed block, which is common with OpenAI-
// compatible gateways translating to the Anthropic protocol), the
// delta path emitted nothing — filtering the assembled blocks out
// would drop the assistant's final text on the floor while still
// returning `stopReason: "end_turn"`. Detect that case via
// `textStreamedMessageIds` and emit the assembled blocks as a
// fallback.
//
// The fallback dedup must be per content block, not per message
// id: such gateways frequently deliver one assistant response as
// several `assistant` messages that *share a message id* but each
// carry a *different* block (e.g. an empty `thinking` block, then
// the real `text` block). Flagging the whole id after the first
// block would emit that block, then silently drop the real text
// arriving under the same id. So we key the dedup on the block
// contents, which lets distinct blocks through while still
// collapsing identical re-deliveries (an incremental block
// followed by a consolidated message, or the same message
// delivered twice).
const assistantMessageId =
message.type === "assistant" ? message.message.id : undefined;
const liveStreamed =
!!assistantMessageId && session.textStreamedMessageIds.has(assistantMessageId);
let emittedFallbackBlock = false;
const content =
message.type === "assistant"
? // Handled by stream events above
message.message.content.filter(
(item) => !["text", "thinking"].includes(item.type),
)
? message.message.content.filter((item) => {
if (item.type !== "text" && item.type !== "thinking") {
return true;
}
// Already delivered live via streaming deltas — drop the
// assembled copy to avoid duplication. Note this is
// all-or-nothing per message id: a single delta of either
// kind flags the id, so if a gateway streamed (say)
// thinking deltas but delivered text only in the assembled
// message under that same id, the assembled text is
// dropped here. That mixed mode hasn't been observed —
// gateways stream either everything or nothing — and it is
// not a regression (the old code dropped it unconditionally).
if (liveStreamed) {
return false;
}
// Fallback path: emit each assembled block once, keyed on
// its contents so re-delivered blocks dedup but distinct
// blocks sharing a message id are all forwarded.
const blockText = item.type === "text" ? item.text : item.thinking;
const key = `${assistantMessageId ?? "<unknown>"}\u0000${item.type}\u0000${blockText}`;
if (session.fallbackEmittedBlockKeys.has(key)) {
return false;
}
session.fallbackEmittedBlockKeys.add(key);
emittedFallbackBlock = true;
return true;
})
: message.message.content;
if (emittedFallbackBlock) {
this.logger.log(
`[claude-agent-acp] No streamed text/thinking deltas seen for ` +
`assistant message ${assistantMessageId ?? "<unknown>"}; ` +
`emitting assembled blocks as fallback.`,
);
}
for (const notification of toAcpNotifications(
content,
message.message.role,
@@ -2290,6 +2377,8 @@ export class ClaudeAcpAgent implements Agent {
contextWindowSize:
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
taskState,
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
return {
@@ -3172,7 +3261,6 @@ export function toAcpNotifications(
case "compaction":
case "compaction_delta":
case "advisor_tool_result":
case "mid_conv_system":
break;
default:
@@ -3240,11 +3328,7 @@ export function streamEventToAcpNotifications(
taskState: options?.taskState,
},
);
// No content. `ping` is a Messages-API keep-alive event that the SDK's
// `BetaRawMessageStreamEvent` union doesn't include even though the
// wire format emits it; the `as never` cast lets us no-op it here
// instead of letting it fall through to `unreachable`.
case "ping" as never:
// No content
case "message_start":
case "message_delta":
case "message_stop":

View File

@@ -3,4 +3,4 @@ description: Say hello
argument-hint: name
---
Respond with "Hello $ARGUMENTS" and nothing else.
Respond with "Hello $1" and nothing else.

View File

@@ -32,7 +32,6 @@ import {
ClaudeAcpAgent,
claudeCliPath,
describeAlwaysAllow,
streamEventToAcpNotifications,
type SDKMessageFilter,
} from "../acp-agent.js";
import { Pushable } from "../utils.js";
@@ -239,15 +238,13 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
name: "compact",
});
// Build up enough conversation that there's something to compact. The SDK
// refuses to compact a conversation with too few message groups.
for (let i = 0; i < 6; i++) {
await connection.prompt({
prompt: [{ type: "text", text: `Reply with just the number ${i}.` }],
sessionId: newSessionResponse.sessionId,
});
client.takeReceivedText();
}
// Send something
await connection.prompt({
prompt: [{ type: "text", text: "Hi" }],
sessionId: newSessionResponse.sessionId,
});
// Clear response
client.takeReceivedText();
await connection.prompt({
prompt: [
@@ -260,7 +257,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
});
expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed.");
}, 60000);
}, 30000);
});
describe("tool conversions", () => {
@@ -665,7 +662,6 @@ describe("tool conversions", () => {
server_tool_use: null,
inference_geo: null,
iterations: null,
output_tokens_details: null,
speed: null,
},
context_management: null,
@@ -1548,6 +1544,8 @@ describe("stop reason propagation", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
}
@@ -1692,6 +1690,8 @@ describe("stop reason propagation", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
const response = await agent.prompt({
@@ -1810,6 +1810,409 @@ describe("stop reason propagation", () => {
});
});
describe("assistant text fallback when streaming yields no text deltas", () => {
function createCapturingAgent() {
const notifications: SessionNotification[] = [];
const mockClient = {
sessionUpdate: async (n: SessionNotification) => {
notifications.push(n);
},
extNotification: async () => {},
} as unknown as AgentSideConnection;
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
return { agent, notifications };
}
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
const input = new Pushable<any>();
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage, done } = await iter.next();
if (!done && userMessage) {
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
}
yield* messages;
}
agent.sessions["test-session"] = {
query: messageGenerator() as any,
input,
cancelled: false,
cwd: "/test",
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
}
function makeResult() {
return {
type: "result" as const,
subtype: "success" as const,
stop_reason: null,
is_error: false,
result: "",
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: "test-session",
};
}
function makeAssistantMessage(messageId: string, text: string) {
return makeAssistantMessageWithBlocks(messageId, [{ type: "text", text }]);
}
// Same as makeAssistantMessage but with arbitrary content blocks, so tests
// can reproduce gateways that split a single response (one message id) into
// several `assistant` messages each carrying a different block.
function makeAssistantMessageWithBlocks(messageId: string, content: any[]) {
return {
type: "assistant" as const,
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
message: {
id: messageId,
type: "message" as const,
role: "assistant" as const,
model: "claude-test",
content,
stop_reason: "end_turn",
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
};
}
it("emits the assembled text when no content_block_delta was streamed", async () => {
const { agent, notifications } = createCapturingAgent();
const messageId = "msg_no_stream";
const finalText = "final answer never streamed";
injectSession(agent, [
makeAssistantMessage(messageId, finalText),
makeResult(),
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "go" }],
});
expect(response.stopReason).toBe("end_turn");
const textChunks = notifications
.map((n) => n.update)
.filter(
(u) =>
u.sessionUpdate === "agent_message_chunk" &&
u.content.type === "text" &&
u.content.text === finalText,
);
expect(textChunks.length).toBe(1);
});
it("does not re-emit text already streamed via content_block_delta", async () => {
const { agent, notifications } = createCapturingAgent();
const messageId = "msg_streamed";
const streamedText = "streamed answer";
injectSession(agent, [
{
type: "stream_event",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
event: {
type: "message_start",
message: {
id: messageId,
type: "message",
role: "assistant",
model: "claude-test",
content: [],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
},
},
{
type: "stream_event",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: streamedText },
},
},
makeAssistantMessage(messageId, streamedText),
makeResult(),
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "go" }],
});
expect(response.stopReason).toBe("end_turn");
const textChunks = notifications
.map((n) => n.update)
.filter(
(u) =>
u.sessionUpdate === "agent_message_chunk" &&
u.content.type === "text" &&
u.content.text === streamedText,
);
// Only the streamed content_block_delta should produce a chunk; the
// assembled message must not duplicate it.
expect(textChunks.length).toBe(1);
});
it("fallback emits exactly once when the same assistant message is delivered twice", async () => {
// Regression test: the SDK occasionally delivers the same assembled
// assistant message twice (observed in production logs). Without
// recording the id into `textStreamedMessageIds` after the first
// fallback emit, the second delivery would re-fire the fallback and
// ACP clients would see duplicate text chunks. After the dedupe fix
// the second delivery must be a no-op.
const { agent, notifications } = createCapturingAgent();
const messageId = "msg_delivered_twice";
const finalText = "final answer delivered twice";
injectSession(agent, [
makeAssistantMessage(messageId, finalText),
makeAssistantMessage(messageId, finalText),
makeResult(),
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "go" }],
});
expect(response.stopReason).toBe("end_turn");
const textChunks = notifications
.map((n) => n.update)
.filter(
(u) =>
u.sessionUpdate === "agent_message_chunk" &&
u.content.type === "text" &&
u.content.text === finalText,
);
expect(textChunks.length).toBe(1);
});
it("textStreamedMessageIds is cleared at the start of each new turn", async () => {
// Regression test: assistant message ids are turn-scoped. Without
// per-turn clear, the set would accumulate over the session's lifetime
// and a (rare) reuse of an id across turns would be misclassified as
// "already streamed", silently dropping the fallback emit. The two
// turns below use the same messageId; both should fallback-emit
// exactly once because the set is reset on each `prompt()` entry.
const { agent, notifications } = createCapturingAgent();
const messageId = "msg_reused_across_turns";
const finalText = "answer for both turns";
injectSession(agent, [
makeAssistantMessage(messageId, finalText),
makeResult(),
{ type: "system", subtype: "session_state_changed", state: "idle" },
makeAssistantMessage(messageId, finalText),
makeResult(),
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const first = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "turn 1" }],
});
const second = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "turn 2" }],
});
expect(first.stopReason).toBe("end_turn");
expect(second.stopReason).toBe("end_turn");
const textChunks = notifications
.map((n) => n.update)
.filter(
(u) =>
u.sessionUpdate === "agent_message_chunk" &&
u.content.type === "text" &&
u.content.text === finalText,
);
// Two turns each fallback-emit once.
expect(textChunks.length).toBe(2);
});
it("emits the text block when it arrives in a later same-id message than the thinking block", async () => {
// Production regression (OpenAI-compatible gpt-5 gateway): a single
// assistant response (one message id) is delivered as *separate*
// `assistant` messages — an empty `thinking` block first, then the real
// `text` block — both sharing the same id and neither streamed via
// content_block_delta. Deduping at message-id granularity flagged the id
// on the thinking block and then dropped the text, so the client saw the
// fallback fire but received an empty answer. The fallback must key on the
// block contents so the later text block is still emitted.
const { agent, notifications } = createCapturingAgent();
const messageId = "resp_split_blocks";
const finalText = "=== Ticket Review Summary === approved";
injectSession(agent, [
makeAssistantMessageWithBlocks(messageId, [
{ type: "thinking", thinking: "", signature: "sig" },
]),
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: finalText }]),
makeResult(),
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "go" }],
});
expect(response.stopReason).toBe("end_turn");
const textChunks = notifications
.map((n) => n.update)
.filter(
(u) =>
u.sessionUpdate === "agent_message_chunk" &&
u.content.type === "text" &&
u.content.text === finalText,
);
expect(textChunks.length).toBe(1);
});
it("emits distinct text blocks that share a message id", async () => {
// A gateway may split a multi-block answer (thinking + text + a second
// text) across same-id messages. Every distinct text block must reach the
// client exactly once.
const { agent, notifications } = createCapturingAgent();
const messageId = "resp_multi_text";
const firstText = "我先直接执行审批并返回工单结果。";
const secondText = "Ticket approved.";
injectSession(agent, [
makeAssistantMessageWithBlocks(messageId, [
{ type: "thinking", thinking: "considering options", signature: "sig" },
]),
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: firstText }]),
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: secondText }]),
makeResult(),
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "go" }],
});
expect(response.stopReason).toBe("end_turn");
const updates = notifications.map((n) => n.update);
const firstChunks = updates.filter(
(u) =>
u.sessionUpdate === "agent_message_chunk" &&
u.content.type === "text" &&
u.content.text === firstText,
);
const secondChunks = updates.filter(
(u) =>
u.sessionUpdate === "agent_message_chunk" &&
u.content.type === "text" &&
u.content.text === secondText,
);
expect(firstChunks.length).toBe(1);
expect(secondChunks.length).toBe(1);
});
it("does not duplicate text when an incremental block is followed by a consolidated message", async () => {
// Some gateways deliver an incremental per-block `assistant` message and
// then a consolidated `assistant` message (same id) that repeats that
// block alongside others. The repeated block must not be emitted twice.
const { agent, notifications } = createCapturingAgent();
const messageId = "resp_consolidated";
const thinking = "step-by-step reasoning";
const finalText = "the answer";
injectSession(agent, [
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: finalText }]),
makeAssistantMessageWithBlocks(messageId, [
{ type: "thinking", thinking, signature: "sig" },
{ type: "text", text: finalText },
]),
makeResult(),
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "go" }],
});
expect(response.stopReason).toBe("end_turn");
const updates = notifications.map((n) => n.update);
const textChunks = updates.filter(
(u) =>
u.sessionUpdate === "agent_message_chunk" &&
u.content.type === "text" &&
u.content.text === finalText,
);
const thoughtChunks = updates.filter(
(u) =>
u.sessionUpdate === "agent_thought_chunk" &&
u.content.type === "text" &&
u.content.text === thinking,
);
expect(textChunks.length).toBe(1);
expect(thoughtChunks.length).toBe(1);
});
});
describe("session/close", () => {
function createMockAgent() {
const mockClient = {
@@ -1851,6 +2254,8 @@ describe("session/close", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
return agent.sessions[sessionId]!;
}
@@ -1935,6 +2340,8 @@ describe("session/delete", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
return agent.sessions[sessionId]!;
}
@@ -2036,6 +2443,8 @@ describe("getOrCreateSession param change detection", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
return agent.sessions[sessionId]!;
}
@@ -2271,6 +2680,8 @@ describe("usage_update computation", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
}
@@ -3171,6 +3582,8 @@ describe("emitRawSDKMessages", () => {
emitRawSDKMessages,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
}
@@ -3399,6 +3812,8 @@ describe("result origin handling", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
}
@@ -3574,6 +3989,8 @@ describe("memory_recall handling", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
}
@@ -3804,6 +4221,8 @@ describe("post-error recovery", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
textStreamedMessageIds: new Set<string>(),
fallbackEmittedBlockKeys: new Set<string>(),
};
return { interrupt };
}
@@ -3876,38 +4295,3 @@ describe("post-error recovery", () => {
expect(session.pendingMessages.size).toBe(0);
});
});
describe("streamEventToAcpNotifications", () => {
it("treats `ping` keep-alive events as no-ops without logging to stderr", () => {
const errors: unknown[][] = [];
const logger = {
log: () => {},
error: (...args: unknown[]) => {
errors.push(args);
},
};
const pingMessage = {
type: "stream_event",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
// The SDK's typed `BetaRawMessageStreamEvent` union doesn't include
// `ping`, but the API emits it on the wire and the SDK passes it
// through. Cast through `unknown` to feed the realistic runtime shape.
event: { type: "ping" } as unknown,
} as Parameters<typeof streamEventToAcpNotifications>[0];
const result = streamEventToAcpNotifications(
pingMessage,
"test-session",
{},
{ sessionUpdate: async () => {} } as unknown as Parameters<
typeof streamEventToAcpNotifications
>[3],
logger,
);
expect(result).toEqual([]);
expect(errors).toEqual([]);
});
});

View File

@@ -15,10 +15,7 @@ const mockQuery = vi.hoisted(() =>
})),
);
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
)),
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: mockQuery,
}));