Compare commits

..

4 Commits

Author SHA1 Message Date
acp-release-bot[bot]
d5e1b7fa72 chore(main): release 0.49.0 (#795)
🤖 I have created a release *beep* *boop*
---


##
[0.49.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.48.0...v0.49.0)
(2026-06-22)


### Features

* Update to claude-agent-sdk 0.3.185
([#798](https://github.com/agentclientprotocol/claude-agent-acp/issues/798))
([8dc8c86](8dc8c86426))


### Bug Fixes

* Deduplicate streamed assistant blocks by content
([#800](https://github.com/agentclientprotocol/claude-agent-acp/issues/800))
([960f62d](960f62d765))
* Infer 1M context from model descriptions
([#799](https://github.com/agentclientprotocol/claude-agent-acp/issues/799))
([508453c](508453c288))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-06-22 13:48:57 +02:00
Ben Brandt
960f62d765 fix: Deduplicate streamed assistant blocks by content (#800)
It seems some providers are still having issues here...
2026-06-22 12:46:49 +02:00
Ben Brandt
508453c288 fix: Infer 1M context from model descriptions (#799) 2026-06-22 12:37:07 +02:00
Ben Brandt
8dc8c86426 feat: Update to claude-agent-sdk 0.3.185 (#798) 2026-06-22 12:10:34 +02:00
7 changed files with 353 additions and 110 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.48.0"
".": "0.49.0"
}

View File

@@ -1,5 +1,18 @@
# Changelog
## [0.49.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.48.0...v0.49.0) (2026-06-22)
### Features
* Update to claude-agent-sdk 0.3.185 ([#798](https://github.com/agentclientprotocol/claude-agent-acp/issues/798)) ([8dc8c86](https://github.com/agentclientprotocol/claude-agent-acp/commit/8dc8c864263fa50d03bec7ebff7aee776604cb3d))
### Bug Fixes
* Deduplicate streamed assistant blocks by content ([#800](https://github.com/agentclientprotocol/claude-agent-acp/issues/800)) ([960f62d](https://github.com/agentclientprotocol/claude-agent-acp/commit/960f62d76582ae5c9c5575ab66974809049ce1d0))
* Infer 1M context from model descriptions ([#799](https://github.com/agentclientprotocol/claude-agent-acp/issues/799)) ([508453c](https://github.com/agentclientprotocol/claude-agent-acp/commit/508453c288b4a12701abd507199e7fa0ab172171))
## [0.48.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.47.0...v0.48.0) (2026-06-19)

View File

@@ -40,7 +40,7 @@ export default [
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always"],
eqeqeq: ["error", "smart"],
curly: ["error", "all"],
},
},

88
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.48.0",
"version": "0.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.48.0",
"version": "0.49.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.28.1",
"@anthropic-ai/claude-agent-sdk": "0.3.183",
"@anthropic-ai/claude-agent-sdk": "0.3.185",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
@@ -41,22 +41,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.183",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.183.tgz",
"integrity": "sha512-rAKws76RKdFu+DK5q9wYN0AGLRdPrw0vGd+TMzSFynFdxwUAehYc9vUKJoK27j2xRoHFdNt+JDsih5BzsUzi+Q==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.185.tgz",
"integrity": "sha512-UDDd0cInvOufmR88btR/Ursnh71sb5scoutNlRS2L0LBdkt7JEqYJ0jt7DnOYHEVhMx8AAcg0eYgvXztcWUnFA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.183",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.183",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.183",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.183",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.183",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.183",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.183",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.183"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.185",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.185",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.185"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -65,9 +65,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.183",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.183.tgz",
"integrity": "sha512-o/+sxwKgXuw6RG5cERWjvcvL1CDSPe/TaXMhax+dq+V4lDOI5iTqg3y5Wfb6dL3xlWoTA2OhWowDQllKbE04LQ==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.185.tgz",
"integrity": "sha512-P2Svxx1IFrS9aw4ylJBtcoJrc/OAOVnDs+o05x4TV7HoHUQUcKZ9XhWOGnadyVT0KLyoktgjyjnaK7Zdtc0Ldg==",
"cpu": [
"arm64"
],
@@ -78,9 +78,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.183",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.183.tgz",
"integrity": "sha512-V7Cf8JeD5EPf4MPomFUlEblCIQI0wg+aWdOSqvfMsDmCBEHljd52CQ3a7W263oVt6I7QUfRTpX2KNvdma56rDA==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.185.tgz",
"integrity": "sha512-H69m2hIJawzSkCDNO/CPLQCYvDGdlbZdzGTgGF8/IQuB4O81sv8WSAh6TBXwtFXOgEi4HtrUYlxiFDmTHt7hMA==",
"cpu": [
"x64"
],
@@ -91,9 +91,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.183",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.183.tgz",
"integrity": "sha512-qqnSf85D3uBH7kRHLGOMhr7aQrREUZ4hUlCztrkJe/twUT4bmJsO86zamnAf7vOYeCul/sHYwBMmXtOfI+cHZg==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.185.tgz",
"integrity": "sha512-KtXMCoprGYTSPb/A0/kjrO+PpDJAFbHhDd8rjqA4izU51OYjTDkahsGCcSTFM2jA8krOhPqzx/SNVuAVWmexwA==",
"cpu": [
"arm64"
],
@@ -107,9 +107,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.183",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.183.tgz",
"integrity": "sha512-ua6XaiCcYYKfd0CY9cjoyYnTQWI2GocpbP7Gwvmr+9taW/lTpQGVQH3FrF2VFKXVSt7AaVE6Z7WN9oPxcxl3VA==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.185.tgz",
"integrity": "sha512-1GRpKYrg5foomW+qLrxm/k2R/5PEU7RAuEdX65HV6lFMJR3HsGj8qfBq4KCJHTS3r4YdVrFLvPQ/mUqbY6klIA==",
"cpu": [
"arm64"
],
@@ -123,9 +123,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.183",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.183.tgz",
"integrity": "sha512-GvfNp9XsKWFvr0EH3NgX2pqzHPYhFeqhOmVc4A+2e+GdxBMYUEa0ylfPLJItahQU9FkTJAw2oQNnzTHPPm1GxA==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.185.tgz",
"integrity": "sha512-8U5wu6dNcyzRkwoae0Gu5ZHs6lRSu8CXqd9yrilX3fHR9kKICrm76bLu5q2auuSc/8dx6VL5ZXqSzFKIMp//zw==",
"cpu": [
"x64"
],
@@ -139,9 +139,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.183",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.183.tgz",
"integrity": "sha512-wp+p+4xeZOW+h4InMKR7nIdGlgYftXyR+ErOVz7i2tMRcKuYMoc5jRIYjdJkuETMSr5nsRj0rDeO5gG7M6MGZQ==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.185.tgz",
"integrity": "sha512-YAky0Oez+YYrlcas/xDiaCm9AUX0z1YwM+pnk+CFxEr/ICmc7MbiFsnEIXFLPHaox9bE5iOzw4LS4b/c1tIJRg==",
"cpu": [
"x64"
],
@@ -155,9 +155,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.183",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.183.tgz",
"integrity": "sha512-cGyMC9YDsrCQ4av74dMVa2liOb5oLFX4+1SxwJGRCW7mBVBazcM6rg7ifWbd9XfkDhNPKOIfyiloipyMfoaE4g==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.185.tgz",
"integrity": "sha512-pJEOaCLQQQWSyBeseR/GAn4eEp4WtMDP/o5yPMuuAsNWeoJXM7cY4j/N3KBE6GZofgnYdWEXILF2uw3SjbtINA==",
"cpu": [
"arm64"
],
@@ -168,9 +168,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.183",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.183.tgz",
"integrity": "sha512-h/XzbrSmXGroTk/FYKR6J4/8G9vDb1HUUUeNXeBGqGW1kppIiWPJKLRzjtSe0brVjADOKOT6tE5IHK0mV/1gBw==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.185.tgz",
"integrity": "sha512-/Kf9XneSuIkSHNwrxOVY0XT7RovAEcx5nDASKzku4994cZz54otU5mT2jT6dwevDLEStfgts5x/A4z0AePwVTQ==",
"cpu": [
"x64"
],
@@ -2954,9 +2954,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.13",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz",
"integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==",
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
@@ -3366,9 +3366,9 @@
"peer": true
},
"node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.48.0",
"version": "0.49.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -61,7 +61,7 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.28.1",
"@anthropic-ai/claude-agent-sdk": "0.3.183",
"@anthropic-ai/claude-agent-sdk": "0.3.185",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {

View File

@@ -1024,12 +1024,17 @@ export class ClaudeAcpAgent {
// (whose delta events don't carry it) can all be tagged with the same,
// replay-stable id.
let currentStreamMessageId: string | undefined;
// Per-message-id record of which assistant content actually streamed live
// via `stream_event` deltas, split by block type, so the consolidated
// `assistant` message can drop the duplicate blocks that already reached the
// client as chunks while still forwarding any that a non-streaming gateway
const streamedTextIds = new Set<string>();
const streamedThinkingIds = new Set<string>();
// The text/thinking blocks that have actually streamed live as
// `stream_event` deltas for the message the next consolidated `assistant`
// will repeat, in stream order, each accumulated to its full streamed text.
// The consolidated handler diffs each assembled block against these and
// forwards only the un-streamed remainder — nothing if it streamed in full
// (the common case), the whole block if it never streamed (a non-streaming
// gateway), or just the tail if the stream was cut short mid-block. Matching
// on content rather than the Anthropic message id makes dedupe robust to
// gateways that don't carry a stable/matching id across the stream and the
// consolidated message. Reset after each consolidated message consumes it.
const streamedBlocks: { index: number; type: "text" | "thinking"; text: string }[] = [];
// Stop reason accumulated for the active turn (result subtype, refusal,
// max_tokens, …). Reset per turn; read when the turn settles at idle.
let stopReason: StopReason = "end_turn";
@@ -1041,12 +1046,14 @@ export class ClaudeAcpAgent {
lastAssistantError = undefined;
lastRefusalExplanation = null;
compactionInProgress = false;
// Do NOT reset currentStreamMessageId here. `message_start` sets it per
// message; clearing it on turn activation drops the id for any block that
// streams after a mid-message activation (the replayed user echo with
// --replay-user-messages), so those ids never enter streamedTextIds and the
// consolidated assistant message re-emits already-streamed text as a
// duplicate. #785 stopped resetting streamedTextIds here but left this line.
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
// activation can fire mid-message (the replayed user echo with
// --replay-user-messages lands between a message's blocks); clearing the
// streamed-content record on activation would drop the blocks that
// streamed before the echo, so the consolidated assistant message would
// re-emit them as duplicates. streamedBlocks is bounded instead by being
// cleared when each consolidated message consumes it. #785 stopped
// resetting the streamed-content tracking here but left this line.
stopReason = "end_turn";
session.accumulatedUsage = {
inputTokens: 0,
@@ -1682,21 +1689,52 @@ export class ClaudeAcpAgent {
// it) can all be tagged with the same, replay-stable id.
if (message.event.type === "message_start") {
currentStreamMessageId = message.event.message.id || undefined;
// A new top-level message starts: clear any streamed-content
// residue from a prior message that never reached its
// consolidated reset — a cancelled turn breaks out before the
// reset, and the synthetic-auth/system/local-command paths
// `break` early too. Block indices restart at 0 each message, so
// leftover entries would otherwise collide with this message's
// blocks and re-emit (or truncate) already-streamed text. Gated on
// `parent_tool_use_id === null` so a subagent stream can't clear
// the top-level record. Fires once, before any of this message's
// blocks, so it doesn't disturb the mid-message turn-activation
// path the way resetting on turn activation would.
if (message.parent_tool_use_id === null) {
streamedBlocks.length = 0;
}
}
// Record that this top-level message id actually streamed text/
// thinking, so the `assistant` case below knows its assembled blocks
// are duplicates (filter them) rather than the only copy (forward
// them). Gated on `parent_tool_use_id === null` so a subagent stream
// can't attribute its content to the top-level message id.
// Accumulate the text/thinking actually streamed live, so the
// `assistant` case below can diff its assembled blocks against what
// already reached the client as chunks and forward only the
// remainder. Gated on `parent_tool_use_id === null` so a subagent
// stream can't attribute its content to the top-level message.
// Contiguous deltas of the same block (same index and type) extend
// the current entry; anything else opens a new one.
if (
currentStreamMessageId &&
message.parent_tool_use_id === null &&
message.event.type === "content_block_delta"
) {
if (message.event.delta.type === "text_delta") {
streamedTextIds.add(currentStreamMessageId);
} else if (message.event.delta.type === "thinking_delta") {
streamedThinkingIds.add(currentStreamMessageId);
const delta = message.event.delta;
const chunk =
delta.type === "text_delta"
? { type: "text" as const, text: delta.text }
: delta.type === "thinking_delta"
? { type: "thinking" as const, text: delta.thinking }
: undefined;
// Skip empty deltas (some gateways emit empty thinking chunks —
// #793): appending "" is a no-op, but pushing a "" entry would
// create a block the consolidated handler's `text.length > 0`
// guard can never consume, stalling the diff cursor and
// re-emitting the next block as a duplicate.
if (chunk && chunk.text.length > 0) {
const index = message.event.index;
const last = streamedBlocks[streamedBlocks.length - 1];
if (last && last.index === index && last.type === chunk.type) {
last.text += chunk.text;
} else {
streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
}
}
}
if (
@@ -1907,41 +1945,66 @@ export class ClaudeAcpAgent {
let content: typeof message.message.content;
if (message.type === "assistant" && message.parent_tool_use_id === null) {
// Top-level assistant message: drop text/thinking blocks already
// streamed live as chunks, and forward (as a fallback) any that were
// not, so non-streaming gateways still deliver the final answer.
const id = messageIdForGrouping(message);
content = message.message.content.filter((item) => {
// Non-text blocks (tool_use, etc.) always pass through; their own
// dedupe (`toolUseCache`) collapses the streamed/assembled pair.
// Top-level assistant message: each text/thinking block may have
// already been streamed live as deltas. Diff each against what
// streamed (`streamedBlocks`, in document order) and forward only
// the un-streamed remainder — nothing if it streamed in full (the
// common case), the whole block if it never streamed (a
// non-streaming gateway), or just the tail if the stream was cut
// short mid-block. `streamPos` walks the streamed blocks in step
// with the assembled text/thinking blocks; tool_use and other
// blocks pass through untouched (their own `toolUseCache` collapses
// the streamed/assembled pair) without advancing it.
const blocks = message.message.content;
const kept: typeof blocks = [];
let streamPos = 0;
for (const item of blocks) {
if (item.type !== "text" && item.type !== "thinking") {
return true;
kept.push(item);
continue;
}
// Already delivered live as a chunk of this exact type — drop the
// duplicate. Checked per type so streaming one (e.g. text) doesn't
// suppress an un-streamed block of the other (e.g. thinking).
const streamedLive =
id !== undefined &&
(item.type === "text" ? streamedTextIds : streamedThinkingIds).has(id);
if (streamedLive) {
return false;
const full = item.type === "text" ? item.text : item.thinking;
// Empty assembled blocks carry nothing (some gateways emit an
// empty `thinking` block before the real text) — drop them.
if (full.length === 0) {
continue;
}
// Empty assembled blocks carry nothing (some gateways emit an empty
// `thinking` block before the real text) — don't forward stray
// empty chunks.
const text = item.type === "text" ? item.text : item.thinking;
if (text.length === 0) {
return false;
// A streamed block of the same type whose accumulated text is a
// prefix of this one was already (at least partly) delivered as
// chunks; consume it and forward only what's left. A non-empty
// streamed text is required so an empty/aborted streamed block
// doesn't swallow the assembled copy.
const streamed = streamedBlocks[streamPos];
if (
streamed &&
streamed.type === item.type &&
streamed.text.length > 0 &&
full.startsWith(streamed.text)
) {
streamPos++;
const remainder = full.slice(streamed.text.length);
if (remainder.length === 0) {
continue;
}
// Overwrite in place with just the un-streamed tail (the
// assembled message isn't read again after this) so the block
// keeps its exact SDK type.
if (item.type === "text") {
item.text = remainder;
} else {
item.thinking = remainder;
}
kept.push(item);
continue;
}
return true;
});
// The consolidated message is the last place this id is needed for
// dedupe — drop it so the streamed-id sets stay bounded to in-flight
// streams rather than growing for the session's whole life.
if (id !== undefined) {
streamedTextIds.delete(id);
streamedThinkingIds.delete(id);
// Not matched: never streamed (or the stream diverged from the
// assembled text) — forward the block in full.
kept.push(item);
}
content = kept;
// Consumed: reset so the next message's blocks accumulate fresh and
// the record stays bounded to the in-flight message.
streamedBlocks.length = 0;
} else if (message.type === "assistant") {
// Subagent assistant message (`parent_tool_use_id !== null`). It is
// never streamed live and its text/thinking is internal to the tool
@@ -2652,19 +2715,27 @@ export class ClaudeAcpAgent {
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
} else if (configId === "model") {
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
// modes below; its `displayName`/`description` also let us infer the
// context window for semantic aliases (e.g. `default`) whose ID alone
// carries no "1m" token.
const newModelInfo = session.modelInfos.find((m) => m.value === value);
if (session.models.currentModelId !== value) {
// The cached context window was learned for the previous model; reset
// to the new model's heuristic so mid-stream updates between now and
// the next `result` reflect the user's selection instead of the old
// model's window.
session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW;
session.contextWindowSize =
inferContextWindowFromModel(
value,
newModelInfo?.displayName,
newModelInfo?.description,
) ?? DEFAULT_CONTEXT_WINDOW;
}
session.models = { ...session.models, currentModelId: value };
// Recompute availableModes for the new model and clamp the current
// mode if the SDK no longer offers it (today: "auto" on Haiku).
// `ModelInfo.supportsAutoMode` is the canonical SDK signal.
const newModelInfo = session.modelInfos.find((m) => m.value === value);
const newAvailableModes = buildAvailableModes(newModelInfo);
// Capture BEFORE mutating session.modes so the log message reflects
// the invalidated mode rather than "default".
@@ -3219,7 +3290,11 @@ export class ClaudeAcpAgent {
abortController,
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
contextWindowSize:
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
inferContextWindowFromModel(
models.currentModelId,
currentModelInfo?.displayName,
currentModelInfo?.description,
) ?? DEFAULT_CONTEXT_WINDOW,
taskState,
toolUseCache: {},
messageIdToUuid: new Map(),
@@ -4377,14 +4452,21 @@ function commonPrefixLength(a: string, b: string) {
return i;
}
/** Best-effort first guess of a model's context window from its ID, used only
* as a fallback when the SDK's authoritative `getContextUsage` is unavailable
* (and until a `result` message arrives with the `modelUsage` value).
/** Best-effort first guess of a model's context window, used only as a
* fallback when the SDK's authoritative `getContextUsage` is unavailable (and
* until a `result` message arrives with the `modelUsage` value).
*
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
* matching things like "10m" or embedded substrings. */
function inferContextWindowFromModel(model: string): number | null {
if (/\b1m\b/i.test(model)) return 1_000_000;
* matching things like "10m" or embedded substrings. Semantic aliases like
* `default` carry no such token in the ID, but the SDK's human-facing
* `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
* pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
* `ModelInfo` exposes no structured context-window field, so this text scan is
* the only pre-`result` signal available. A miss falls back to the default
* window and is corrected by `result.modelUsage` within one turn. */
function inferContextWindowFromModel(...texts: Array<string | undefined>): number | null {
if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000;
return null;
}

View File

@@ -3072,6 +3072,26 @@ describe("usage_update computation", () => {
expect(usageUpdates[1].update.size).toBe(1000000);
});
it("infers the 1M window from a model's description when the ID lacks a 1m token (issue #596)", async () => {
// Semantic aliases like `default` resolve to a 1M-context model but carry
// no "1m" token in the modelId — the SDK signals 1M only via the
// human-facing displayName/description (e.g. "Opus 4.7 with 1M context").
// Inference must read those so the session reports the correct window from
// the first mid-stream update instead of the 200k placeholder.
const { agent } = createMockAgentWithCapture();
injectSession(agent, [{ type: "system", subtype: "session_state_changed", state: "idle" }]);
const session = agent.sessions["test-session"];
session.models = { currentModelId: "claude-sonnet-4-6", availableModels: [] };
session.modelInfos = [
{ value: "default", displayName: "Default", description: "Opus 4.7 with 1M context" },
] as any;
expect(session.contextWindowSize).toBe(200000);
await (agent as any).applyConfigOptionValue("test-session", session, "model", "default");
expect(session.contextWindowSize).toBe(1000000);
});
it("result with no matching modelUsage preserves the learned window", async () => {
// A turn whose `result.modelUsage` doesn't contain the current top-level
// model (e.g. no top-level assistant message, or only a subagent ran) must
@@ -3872,6 +3892,134 @@ describe("assembled assistant text fallback", () => {
expect(thoughtChunkTexts(updates)).toEqual(["private reasoning"]);
});
it("forwards only the un-streamed remainder when the stream is cut short mid-block", async () => {
const { agent, updates } = createMockAgentWithCapture();
// The stream stops partway ("hello ") but the consolidated message carries
// the whole block ("hello world"). The streamed prefix must not be re-sent,
// and the un-streamed tail must still reach the client — dropping the whole
// assembled block would truncate the answer to "hello ".
injectSession(agent, [
messageStart("msg-partial"),
textDelta("hello "),
assistantMessage("msg-partial", [{ type: "text", text: "hello world" }]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
// The streamed prefix, then just the tail from the consolidated message.
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
});
it("dedupes streamed text even when the consolidated message carries a different id", async () => {
const { agent, updates } = createMockAgentWithCapture();
// Some gateways assign one id during the stream and a different one (or only
// a uuid) on the assembled message. Dedupe must key on content, not the id,
// or the consolidated block re-emits already-streamed text as a duplicate.
injectSession(agent, [
messageStart("msg-stream-id"),
textDelta("hello "),
textDelta("world"),
assistantMessage("msg-DIFFERENT-id", [{ type: "text", text: "hello world" }]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
// Only the streamed deltas — the assembled copy is deduped despite the id
// mismatch.
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
});
it("dedupes a streamed text block even when an empty thinking delta precedes it", async () => {
// An empty thinking delta (some gateways emit them — #793) must not create
// a zero-length streamedBlocks entry: that entry can never satisfy the
// consolidated handler's `text.length > 0` guard, so it would stall the
// diff cursor and re-emit the real, already-streamed text as a duplicate.
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
messageStart("msg-empty-thinking"),
thinkingDelta(""),
textDelta("real answer"),
assistantMessage("msg-empty-thinking", [{ type: "text", text: "real answer" }]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
// The streamed text appears once; the consolidated copy is deduped.
expect(messageChunkTexts(updates)).toEqual(["real answer"]);
});
it("does not re-emit the next turn's text after a turn is cancelled mid-stream", async () => {
// Regression: streamedBlocks is reset inside the consolidated-assistant
// branch, but a cancelled turn `break`s out before reaching it (the
// `if (session.cancelled) break;` guard), and streamedBlocks is
// session-scoped — so a cancelled turn's streamed text used to leak into
// the next turn. Block indices restart at 0 per message, so the leftover
// "Hello there" would fuse with turn 2's first block and make its
// consolidated copy fail the prefix dedupe, re-emitting "Second answer" as
// a duplicate. The fix resets streamedBlocks on each top-level
// `message_start`, bounding the record to one in-flight message.
const { agent, updates } = createMockAgentWithCapture();
let releaseCancel!: () => void;
const cancelled = new Promise<void>((resolve) => {
releaseCancel = resolve;
});
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const u1 = await iter.next();
yield userEcho(u1.value); // activate turn 1
yield messageStart("msg-1");
yield textDelta("Hello ");
yield textDelta("there"); // streamedBlocks = [{ index: 0, text: "Hello there" }]
await cancelled; // hold until the test has cancelled turn 1
// Turn 1's consolidated message arrives while cancelled → hits the
// `if (session.cancelled) break;` guard, skipping the streamedBlocks
// reset. The leftover entry must not survive into turn 2.
yield assistantMessage("msg-1", [{ type: "text", text: "Hello there" }]);
yield idle; // settles turn 1 as cancelled
const u2 = await iter.next();
yield userEcho(u2.value); // activate turn 2
yield messageStart("msg-2"); // resets streamedBlocks (the fix)
yield textDelta("Second answer");
yield assistantMessage("msg-2", [{ type: "text", text: "Second answer" }]);
yield result();
yield idle;
}
return messageGenerator();
});
const first = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "first" }],
});
// Wait until turn 1's deltas have streamed before cancelling.
const deadline = Date.now() + 1000;
while (!messageChunkTexts(updates).includes("there")) {
if (Date.now() > deadline) throw new Error("turn 1 stream never arrived");
await new Promise((r) => setTimeout(r, 1));
}
await agent.cancel({ sessionId: "test-session" });
releaseCancel();
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "second" }] });
// Turn 2's text appears exactly once (the live delta); the consolidated copy
// is deduped despite the cancelled turn's leftover streamed text.
expect(messageChunkTexts(updates).filter((t) => t === "Second answer")).toEqual([
"Second answer",
]);
});
it("does not leak subagent assistant text into the top-level feed", async () => {
const { agent, updates } = createMockAgentWithCapture();
// Subagent assistant messages (parent_tool_use_id !== null) are never