Skip to content

Commit 7b376d0

Browse files
committed
clear stale emitted tool calls, trim comments
1 parent 3400ecf commit 7b376d0

8 files changed

Lines changed: 98 additions & 364 deletions

File tree

packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,7 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
185185
expect(text).toContain(tc.commandInMessage);
186186
}
187187
} else {
188-
// No unsupported chunk. The idle is absorbed; the stream then ends
189-
// without the turn ever starting, so the consumer rejects the queued
190-
// prompt with the session-ended error.
188+
// Idle absorbed; the stream then ends before the turn ever starts.
191189
await expect(promptPromise).rejects.toThrow(/session has ended/);
192190
expect(
193191
findUnsupportedChunkText(client.sessionUpdate.mock.calls),
@@ -233,8 +231,7 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => {
233231
});
234232

235233
await new Promise((resolve) => setImmediate(resolve));
236-
// Activate the turn via its echo so cancel() arms the backstop for it
237-
// (a still-queued turn is settled directly, with no SDK work to force).
234+
// cancel() only arms the backstop for an activated (echoed) turn.
238235
echoQueuedTurn(agent, query);
239236
await new Promise((resolve) => setImmediate(resolve));
240237

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 55 additions & 214 deletions
Large diffs are not rendered by default.

packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts

Lines changed: 22 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,8 @@ interface AnthropicMessageWithContent {
6666
type ChunkHandlerContext = {
6767
sessionId: string;
6868
toolUseCache: ToolUseCache;
69-
/** Tool_use ids already surfaced as a `tool_call` (a permission request
70-
* emits one eagerly, before the tool_use block streams). When the streamed
71-
* block arrives second it refines with a `tool_call_update` instead of
72-
* emitting a duplicate `tool_call`. Mutated in place; pruned at
73-
* `tool_result` time alongside `toolUseCache`. */
69+
/** Tool_use ids already surfaced as a `tool_call` (permission requests emit
70+
* eagerly); the second emitter refines instead of duplicating. */
7471
emittedToolCalls?: Set<string>;
7572
fileContentCache: { [key: string]: string };
7673
enrichedReadCache?: EnrichedReadCache;
@@ -87,17 +84,11 @@ type ChunkHandlerContext = {
8784
};
8885

8986
/**
90-
* The text/thinking blocks that actually streamed live as `stream_event`
91-
* deltas for the message the next consolidated `assistant` will repeat, in
92-
* stream order, each accumulated to its full streamed text. The consolidated
93-
* handler diffs each assembled block against these and forwards only the
94-
* un-streamed remainder — nothing if it streamed in full (the common case),
95-
* the whole block if it never streamed (a non-streaming gateway), or just the
96-
* tail if the stream was cut short mid-block. Matching on content rather than
97-
* the Anthropic message id makes dedupe robust to gateways that don't carry a
98-
* stable/matching id across the stream and the consolidated message. Cleared
99-
* at each top-level `message_start` and again once a consolidated message
100-
* consumes it, so the record stays bounded to the in-flight message.
87+
* Text/thinking actually streamed live for the in-flight message, in order.
88+
* The consolidated assistant message prefix-diffs its blocks against this and
89+
* forwards only the un-streamed remainder. Content matching (not message ids)
90+
* keeps dedupe robust to gateways whose ids don't line up; cleared per
91+
* message so it stays bounded.
10192
*/
10293
export interface StreamedAssistantBlocks {
10394
blocks: { index: number; type: "text" | "thinking"; text: string }[];
@@ -211,9 +202,6 @@ function handleToolUseChunk(
211202
ctx: ChunkHandlerContext,
212203
): SessionUpdate | null {
213204
const alreadyCached = chunk.id in ctx.toolUseCache;
214-
// A permission request may have already surfaced this tool call to the
215-
// client (see `emittedToolCalls`); if so this streamed encounter refines it
216-
// rather than emitting a duplicate `tool_call`.
217205
const alreadyEmitted =
218206
alreadyCached || ctx.emittedToolCalls?.has(chunk.id) === true;
219207
ctx.toolUseCache[chunk.id] = chunk;
@@ -846,11 +834,8 @@ export async function handleSystemMessage(
846834
break;
847835
}
848836
case "informational": {
849-
// Free-form notice from the SDK (e.g. why a UserPromptSubmit/Stop hook
850-
// blocked continuation). Surface the text so the user sees it instead
851-
// of a silent stop. ACP's agent_message_chunk has no severity field, so
852-
// fold the level into the text for the more prominent levels ('info' is
853-
// transcript-only noise — leave plain).
837+
// Surface hook-blocked stops; the level is folded into the text since
838+
// agent_message_chunk has no severity field.
854839
const informationalText =
855840
message.level === "info"
856841
? message.content
@@ -1022,26 +1007,16 @@ export async function handleStreamEvent(
10221007

10231008
const streamed = context.streamedAssistantBlocks;
10241009
if (streamed) {
1025-
// A new top-level message starts: clear any streamed-content residue from
1026-
// a prior message that never reached its consolidated reset (a cancelled
1027-
// turn breaks out before it). Block indices restart at 0 each message, so
1028-
// leftover entries would otherwise collide with this message's blocks and
1029-
// re-emit (or truncate) already-streamed text. Gated on
1030-
// `parent_tool_use_id === null` so a subagent stream can't clear the
1031-
// top-level record.
1010+
// Clear residue from a message that never reached its consolidated reset
1011+
// (e.g. a cancelled turn); indices restart per message and would collide.
10321012
if (
10331013
message.event.type === "message_start" &&
10341014
message.parent_tool_use_id === null
10351015
) {
10361016
streamed.blocks.length = 0;
10371017
}
1038-
// Accumulate the text/thinking actually streamed live, so the assistant
1039-
// handler can diff its assembled blocks against what already reached the
1040-
// client as chunks and forward only the remainder. Only top-level streams
1041-
// are recorded — subagent text is never streamed and must stay filtered,
1042-
// as it is internal to the tool call. Contiguous deltas of the same block
1043-
// (same index and type) extend the current entry; anything else opens a
1044-
// new one.
1018+
// Record only top-level streams; subagent text is never streamed and
1019+
// must stay filtered.
10451020
if (
10461021
message.parent_tool_use_id === null &&
10471022
message.event.type === "content_block_delta"
@@ -1053,11 +1028,7 @@ export async function handleStreamEvent(
10531028
: delta.type === "thinking_delta"
10541029
? { type: "thinking" as const, text: delta.thinking }
10551030
: undefined;
1056-
// Skip empty deltas (some gateways emit empty thinking chunks):
1057-
// appending "" is a no-op, but pushing a "" entry would create a block
1058-
// the consolidated handler's `text.length > 0` guard can never consume,
1059-
// stalling the diff cursor and re-emitting the next block as a
1060-
// duplicate.
1031+
// An empty entry would stall the diff cursor in the assistant handler.
10611032
if (chunk && chunk.text.length > 0) {
10621033
const index = message.event.index;
10631034
const last = streamed.blocks[streamed.blocks.length - 1];
@@ -1221,16 +1192,9 @@ function logSpecialMessages(
12211192
}
12221193
}
12231194

1224-
/**
1225-
* Diffs each assistant `text`/`thinking` block against what already streamed
1226-
* live as chunks (`StreamedAssistantBlocks`, in document order) and forwards
1227-
* only the un-streamed remainder — nothing if it streamed in full (the common
1228-
* case), the whole block if it never streamed (a non-streaming gateway), or
1229-
* just the tail if the stream was cut short mid-block. Subagent assistant
1230-
* content (`parent_tool_use_id !== null`) is never streamed and stays
1231-
* internal to its tool call, so it is always dropped, as is everything when
1232-
* no tracker is available (replay).
1233-
*/
1195+
// Forwards only the un-streamed remainder of each assistant text/thinking
1196+
// block: nothing, the whole block (non-streaming gateway) or a cut-short
1197+
// tail. Subagent content and tracker-less replay stay dropped.
12341198
function filterAssistantContent(
12351199
message: SDKAssistantMessage,
12361200
streamed: StreamedAssistantBlocks | undefined,
@@ -1253,10 +1217,8 @@ function filterAssistantContent(
12531217
);
12541218
}
12551219

1256-
// `streamPos` walks the streamed blocks in step with the assembled
1257-
// text/thinking blocks; tool_use and other blocks pass through untouched
1258-
// (their own `toolUseCache` collapses the streamed/assembled pair) without
1259-
// advancing it.
1220+
// streamPos walks the streamed record in step with the assembled
1221+
// text/thinking blocks; other block types pass through without advancing.
12601222
const kept: typeof content = [];
12611223
let streamPos = 0;
12621224
for (const block of content) {
@@ -1265,15 +1227,11 @@ function filterAssistantContent(
12651227
continue;
12661228
}
12671229
const full = block.type === "text" ? block.text : block.thinking;
1268-
// Empty assembled blocks carry nothing (some gateways emit an empty
1269-
// `thinking` block before the real text) — drop them.
12701230
if (full.length === 0) {
12711231
continue;
12721232
}
1273-
// A streamed block of the same type whose accumulated text is a prefix of
1274-
// this one was already (at least partly) delivered as chunks; consume it
1275-
// and forward only what's left. A non-empty streamed text is required so
1276-
// an empty/aborted streamed block doesn't swallow the assembled copy.
1233+
// A same-type streamed prefix means the block (or its head) was already
1234+
// delivered as chunks; consume it and forward only what's left.
12771235
const streamedBlock = streamed.blocks[streamPos];
12781236
if (
12791237
streamedBlock &&
@@ -1286,9 +1244,7 @@ function filterAssistantContent(
12861244
if (remainder.length === 0) {
12871245
continue;
12881246
}
1289-
// Overwrite in place with just the un-streamed tail (the assembled
1290-
// message isn't read again after this) so the block keeps its exact SDK
1291-
// type.
1247+
// Overwrite in place so the block keeps its exact SDK type.
12921248
if (block.type === "text") {
12931249
block.text = remainder;
12941250
} else {
@@ -1297,12 +1253,8 @@ function filterAssistantContent(
12971253
kept.push(block);
12981254
continue;
12991255
}
1300-
// Not matched: never streamed (or the stream diverged from the assembled
1301-
// text) — forward the block in full.
13021256
kept.push(block);
13031257
}
1304-
// Consumed: reset so the next message's blocks accumulate fresh and the
1305-
// record stays bounded to the in-flight message.
13061258
streamed.blocks.length = 0;
13071259
return kept;
13081260
}

packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -570,8 +570,7 @@ export function toolUpdateFromToolResult(
570570
toolResult.is_error &&
571571
toolResult.content &&
572572
(toolResult.content as unknown[]).length > 0 &&
573-
// Bash errors keep flowing through the terminal-output channel below so
574-
// they render as terminal output rather than plain error text.
573+
// Bash errors keep rendering through the terminal-output channel below.
575574
!(toolUse?.name === "Bash" && options?.supportsTerminalOutput)
576575
) {
577576
return toAcpContentUpdate(toolResult.content, true);
@@ -676,11 +675,8 @@ export function toolUpdateFromToolResult(
676675
.map((c: { text?: string }) => c.text ?? "")
677676
.join("\n");
678677
} else {
679-
// Image (or mixed non-text) content. Binary payloads can't be
680-
// streamed through the terminal-output _meta channel, so bypass it
681-
// and surface the blocks as ACP content. This handles the local
682-
// Bash tool's image output, which previously failed the text-only
683-
// guard and was silently dropped.
678+
// Binary payloads can't ride the terminal-output _meta channel;
679+
// surface image/mixed content as ACP content blocks instead.
684680
return toAcpContentUpdate(result, isError === true);
685681
}
686682
}

packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ function createClient(response: Record<string, unknown>) {
1010
return {
1111
sessionUpdate: vi.fn().mockResolvedValue(undefined),
1212
requestPermission,
13-
// Permission requests go through the generic request() so the tool
14-
// call's abort signal rides along as a cancellationSignal; delegate to
15-
// the requestPermission spy so assertions keep one target.
13+
// Delegate so assertions keep one target.
1614
request: vi.fn((_method: string, params: unknown) =>
1715
requestPermission(params),
1816
),

packages/agent/src/adapters/claude/permissions/permission-handlers.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,12 @@ interface ToolHandlerContext {
6565
updateConfigOption: (configId: string, value: string) => Promise<void>;
6666
applySessionMode: (modeId: string) => Promise<void>;
6767
allowedDomains?: string[];
68-
/** Tool_use ids already surfaced to the client as a `tool_call`. Shared
69-
* with the streamed tool_use path so whichever side emits first wins and
70-
* the other refines with a `tool_call_update`. */
68+
/** Shared with the streamed tool_use path; first emitter wins. */
7169
emittedToolCalls?: Set<string>;
7270
supportsTerminalOutput?: boolean;
7371
}
7472

75-
/** Built-in tools whose tool_use never surfaces as a standalone `tool_call`
76-
* (Task* are rendered as plan snapshots at tool_result time; TodoWrite as a
77-
* plan), so a permission prompt for them must not emit a stray one. */
73+
// Task*/TodoWrite render as plans, never as standalone tool_calls.
7874
function shouldEmitToolCall(toolName: string): boolean {
7975
return (
8076
toolName !== "TodoWrite" &&
@@ -85,12 +81,8 @@ function shouldEmitToolCall(toolName: string): boolean {
8581
);
8682
}
8783

88-
/** Emit the `tool_call` a permission request references if it hasn't been
89-
* sent yet, so the client has the tool call before being asked to approve
90-
* it. The SDK may invoke `canUseTool` before the assistant message's
91-
* tool_use block streams to us; the streamed chunk later refines this with a
92-
* `tool_call_update` rather than emitting a duplicate (see
93-
* `emittedToolCalls` in the conversion layer). */
84+
// The SDK can invoke canUseTool before the tool_use block streams; make
85+
// sure the tool_call exists before the client is asked to approve it.
9486
async function ensureToolCallEmitted(
9587
context: ToolHandlerContext,
9688
): Promise<void> {
@@ -129,13 +121,8 @@ async function ensureToolCallEmitted(
129121
});
130122
}
131123

132-
/** Forward a permission request to the client, emitting the referenced
133-
* `tool_call` first and wiring the tool call's abort `signal` through as a
134-
* `cancellationSignal`. When the turn is cancelled while the client's prompt
135-
* is still open the signal aborts, the SDK sends `$/cancel_request`, and the
136-
* client settles the request (a `cancelled` outcome or a rejection). Either
137-
* way callers see the same "Tool use aborted" they already expect, so a
138-
* cancelled dialog no longer leaves the `await` hanging. */
124+
// The cancellationSignal lets a turn cancel dismiss the client's open
125+
// dialog ($/cancel_request) instead of leaving this await hanging.
139126
async function requestPermissionFromClient(
140127
context: ToolHandlerContext,
141128
params: RequestPermissionRequest,

packages/agent/src/adapters/claude/session/models.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,7 @@ export function supportsFastMode(modelId: string): boolean {
7676
return MODELS_WITH_FAST_MODE.has(modelId);
7777
}
7878

79-
/** Map the SDK's tri-state `fast_mode_state` onto the boolean toggle.
80-
* `cooldown` (fast mode temporarily suspended after a rate limit) keeps the
81-
* toggle on so it reflects the user's intent — only an explicit `off` clears
82-
* it. */
79+
// cooldown keeps the toggle on (user intent); only an explicit off clears it.
8380
export function fastModeStateEnabled(state: string | undefined): boolean {
8481
return state !== undefined && state !== "off";
8582
}

packages/agent/src/adapters/claude/types.ts

Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -39,29 +39,13 @@ export type BackgroundTerminal =
3939
pendingOutput: TerminalOutputResponse;
4040
};
4141

42-
/** One in-flight `prompt()` call. A persistent per-session consumer (see
43-
* `runConsumer` in claude-agent.ts) drains the SDK query stream for the whole
44-
* session and settles each Turn's deferred when that turn's outcome is known,
45-
* so `prompt()` itself holds no loop. Turns are processed FIFO: the SDK
46-
* echoes queued user messages back in submission order, so the first
47-
* unsettled queue entry is the turn currently running. */
42+
/** One in-flight `prompt()` call, settled by the session's consumer. */
4843
export type Turn = {
49-
/** uuid stamped on the pushed `SDKUserMessage`; the SDK echoes it back so
50-
* the consumer can match the replayed user message to this turn. */
5144
promptUuid: string;
52-
/** Local-only slash commands (e.g. `/context`) return a result without an
53-
* echo, so the consumer can't promote them via the replay; it falls back
54-
* to promoting the queue head when the result arrives. */
5545
isLocalOnlyCommand: boolean;
56-
/** Leading slash command of the prompt (e.g. "/foo"), if any. Drives the
57-
* unsupported-slash-command gate when idle arrives without an echo. */
5846
commandName?: string;
59-
/** Mirrors the prompt's chunks to the feed/history. Invoked once, when the
60-
* turn activates, preserving the pre-consumer timing where a queued
61-
* prompt's broadcast fired when its turn took over the loop. */
47+
/** Invoked once at activation, matching the pre-consumer broadcast timing. */
6248
broadcast: () => Promise<void>;
63-
/** Set once the deferred has been resolved/rejected, so the consumer never
64-
* settles a turn twice (idle + handoff + stream-end can all race). */
6549
settled: boolean;
6650
resolve: (response: PromptResponse) => void;
6751
reject: (error: unknown) => void;
@@ -90,12 +74,9 @@ export type Session = BaseSession & {
9074
lastPlanFilePath?: string;
9175
lastPlanContent?: string;
9276
effort?: EffortLevel;
93-
/** The user's Fast mode intent. Persists across model switches; the "fast"
94-
* config option is only surfaced while the selected model supports it. */
77+
/** User intent; retained while a non-fast model hides the "fast" option. */
9578
fastModeEnabled: boolean;
96-
/** Last session title pushed to the client via `session_info_update`. The
97-
* SDK auto-generates the title in a background task and persists it to the
98-
* session file; it is polled at turn-end and only pushed on change. */
79+
/** Last title pushed via `session_info_update`, to dedupe turn-end polls. */
9980
lastTitle?: string;
10081
configOptions: SessionConfigOption[];
10182
accumulatedUsage: AccumulatedUsage;
@@ -110,30 +91,15 @@ export type Session = BaseSession & {
11091
contextSize?: number;
11192
/** Persists across prompt() calls so SDK-reported values survive turn boundaries */
11293
lastContextWindowSize?: number;
113-
/** FIFO of in-flight prompts. The head is the turn the SDK is currently
114-
* processing; later entries are queued and will be echoed in order. */
94+
/** FIFO of in-flight prompts; the SDK echoes them back in order. */
11595
turnQueue: Turn[];
116-
/** The turn whose messages the consumer is currently attributing output to
117-
* (the head of `turnQueue` once its user message has been echoed). */
11896
activeTurn: Turn | null;
119-
/** Count of result messages the consumer should treat as orphans and skip.
120-
* When cancel() settles+removes a queued turn, that turn's user message was
121-
* already pushed to the SDK, so the SDK still runs it and emits a result
122-
* with no uuid to match. The SDK processes input FIFO, so those orphan
123-
* results arrive before the next live turn's; skipping exactly this many
124-
* leaves the genuine head untouched. Reset to 0 on every activation. */
97+
/** Echo-less results still owed by turns cancelled while queued. */
12598
pendingOrphanResults: number;
126-
/** The long-lived consumer task. Lazily started on the first `prompt()` and
127-
* kept alive for the session so between-turn/background messages are still
128-
* drained and forwarded. */
12999
consumer?: Promise<void>;
130-
/** Bumped by refreshSession before it swaps `query`/`input`, so the old
131-
* consumer (which captured the previous generation) exits quietly instead
132-
* of tearing down the refreshed session. */
100+
/** Bumped by refreshSession so the retired consumer exits quietly. */
133101
queryGeneration: number;
134-
/** Set once the SDK query stream has terminated (ran to `done` or threw).
135-
* The query iterator is not reusable afterward, so a later `prompt()`
136-
* rejects up front instead of enqueueing onto a dead stream and hanging. */
102+
/** The query iterator ended and can't be revived; new prompts reject. */
137103
queryClosed?: boolean;
138104
cancelController?: AbortController;
139105
forceCancelTimer?: ReturnType<typeof setTimeout>;

0 commit comments

Comments
 (0)