fix: end the tool calls of a response that Claude Code took back (#1616)

## Summary

Follow-up to #1612. When a model refuses, Claude Code takes back the
response it already sent and retries on the fallback model. A tool call
of that response which has no result yet is stopped, and no result ever
comes for it. The adapter never read which messages were taken back, so
that tool call stayed open:

- **v1:** until the turn's end, where the prompt then failed with
`incomplete_tool_call`, although the retry had answered.
- **v2:** past the turn's idle, since a call that ran is left open for
its result.

Both cores now end the call as soon as it is named: `failed` on v1,
`cancelled` on v2, with a text that says why.

## What Claude Code sends

Read from the code of CLI 2.1.323. A refusal fallback cannot be produced
on demand, so none of this was observed live.

- The refused response reaches the host as usual: its stream and its
`assistant` frames, which can hold `tool_use` blocks, and the results of
tools that had finished.
- Claude Code then stops the tools that had not finished and sends no
result for them.
- It names the messages it took back, by the `uuid` their frames were
sent with, in two places: `retracted_message_uuids` of the
`model_refusal_fallback` notice, and `supersedes` of the first
`assistant` frame of the retry.

Since 2.1.323 `supersedes` also names a response whose tool call could
not be parsed. Such a response has stop reason `tool_use` and no
`tool_use` block, so it leaves no tool call open, and this change does
nothing for it.

## What changes

- `RetractedToolCalls` in `src/tool-calls/retracted.ts` (new) keeps the
tool uses of each assistant frame by `uuid`, for the last 1,000 frames,
and gives the tool calls of the messages that a frame takes back. It
reads both signals and gives each tool call once.
- `AcpToolCallRenderer.unfinished` gains a `retracted` case with its own
text: "Claude Code replaced the response that made this tool call, and
stopped the call. If it had started, some of its effects may have
happened."
- v1 closes through the existing `closeOpenToolCalls`, which now also
takes the tool call id. The change in `src/v1/acp-agent.ts` is wiring.
- v2 adds `SessionOutput.closeRetracted`, which shares one private
`close` with `closeUnfinished`.

Only a call that is still open is closed. A call that already has its
result is left alone, and a result that arrives after the close changes
nothing.

## Not in this PR

- **Superseded text stays in the transcript.** ACP v1 has no way to take
a message back. On v2 the `agent_message` upsert could clear one
(`content: []`), but that leaves an empty message whose rendering is up
to the client, and v1 and v2 would then differ. This needs a decision,
so it is left as it is.
- A tool use that streamed but whose `assistant` frame never arrived has
no `uuid` to match, so it is not closed by this.
- No scenario-harness case: its plain and Zed recordings must match the
origin/main baseline, so a deliberate difference there needs a
documented exception first.

## Testing

- `npm run build`, `npm run check`, `npm run test:run` (4,128 tests
pass)
- `src/tests/retracted-tool-calls.test.ts` for the shared class
- One `it.each` in `src/tests/v1/acp-agent.test.ts` and one in
`src/tests/v2/acp-v2.test.ts`, with one row per signal. The v1 rows fail
on main with `incomplete_tool_call`.
- The doubles follow the frames as read from the CLI's code, since the
case cannot be reproduced against the real CLI.
This commit is contained in:
Ben Brandt
2026-11-13 14:27:16 +01:00
committed by GitHub
parent c78bcc7f41
commit 21f3e0430b
8 changed files with 361 additions and 36 deletions

View File

@@ -0,0 +1,63 @@
import { randomUUID } from "node:crypto";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, it } from "vitest";
import { RetractedToolCalls } from "../tool-calls/retracted.js";
/** An assistant message that calls the tools `toolUseIds`, as the SDK sends it. */
function assistant(toolUseIds: string[], supersedes?: string[]) {
return {
type: "assistant",
message: {
id: "msg_1",
type: "message",
role: "assistant",
model: "claude-fable-5",
content: toolUseIds.map((id) => ({ type: "tool_use", id, name: "Read", input: {} })),
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "session",
...(supersedes && { supersedes }),
} as unknown as SDKMessage & { uuid: string };
}
/** The notice of a refusal fallback that took back the messages `retracted`. */
const notice = (retracted: string[]) =>
({
type: "system",
subtype: "model_refusal_fallback",
retracted_message_uuids: retracted,
}) as unknown as SDKMessage;
describe("RetractedToolCalls", () => {
it("gives the tool calls of a message once, though the notice and the retry both name it", () => {
const calls = new RetractedToolCalls();
const refused = assistant(["toolu_a", "toolu_b"]);
calls.sent(refused);
expect(calls.retractedBy(notice([refused.uuid]))).toEqual(["toolu_a", "toolu_b"]);
expect(calls.retractedBy(assistant([], [refused.uuid]))).toEqual([]);
});
it("leaves out a tool call that the replacing message makes itself", () => {
const calls = new RetractedToolCalls();
const refused = assistant(["toolu_gone", "toolu_again"]);
calls.sent(refused);
expect(calls.retractedBy(assistant(["toolu_again"], [refused.uuid]))).toEqual(["toolu_gone"]);
});
it("forgets the oldest of more than 1000 messages", () => {
const calls = new RetractedToolCalls();
const oldest = assistant(["toolu_oldest"]);
calls.sent(oldest);
const others = Array.from({ length: 1000 }, (_, i) => assistant([`toolu_${i}`]));
for (const message of others) calls.sent(message);
expect(calls.retractedBy(notice([oldest.uuid]))).toEqual([]);
expect(calls.retractedBy(notice([others[0]!.uuid]))).toEqual(["toolu_0"]);
});
});

View File

@@ -14033,6 +14033,74 @@ describe("model refusal fallback handling", () => {
),
).toBe(false);
});
// Claude Code takes the response of a model that refuses back, stops its
// tool call without a result, and retries on the fallback model. Its notice,
// or the first message of the retry, names the message that it took back.
// Nothing else ends the call: the prompt used to fail with
// `incomplete_tool_call` at the turn's end, although the retry answered.
it.each<[string, { notice: boolean; retry: boolean }]>([
["the fallback notice", { notice: true, retry: false }],
["the first message of the retry", { notice: false, retry: true }],
])(
"ends the tool call of a response that Claude Code took back, named by %s",
async (_name, names) => {
const { agent, sessionUpdate } = createCapturingAgent();
const assistant = (id: string, block: Record<string, unknown>) => ({
type: "assistant",
message: {
id,
type: "message",
role: "assistant",
model: "claude-fable-5",
content: [block],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
});
const refused = assistant("msg_refused", {
type: "tool_use",
id: "toolu_refused",
name: "Read",
input: { file_path: "/a.log" },
});
injectGeneratorSession(
agent,
makeGenerator([
refused,
refusalFallbackMessage(names.notice ? { retracted_message_uuids: [refused.uuid] } : {}),
{
...assistant("msg_retry", { type: "text", text: "The logs are clean." }),
...(names.retry && { supersedes: [refused.uuid] }),
},
successResult(),
]),
modelStateOverrides,
);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("end_turn");
const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update);
const closed = updates.findIndex(
(u) => u.toolCallId === "toolu_refused" && u.status === "failed",
);
expect(JSON.stringify(updates[closed])).toContain(
"Claude Code replaced the response that made this tool call",
);
// Before the retry's answer.
expect(closed).toBeLessThan(
updates.findIndex((u) => u.content?.text === "The logs are clean."),
);
},
);
});
describe("MCP server startup failures", () => {

View File

@@ -1493,20 +1493,7 @@ describe("ACP v2 sessions", () => {
it("reports a refusal fallback with a notice and follows the model it swapped to", async () => {
sdk.initializationResult = async () => ({ models: [OPUS, HAIKU] });
scriptTurns(async function* (options) {
yield {
type: "system",
subtype: "model_refusal_fallback",
trigger: "refusal",
direction: "retry",
scope: "session",
original_model: "claude-opus-4-8",
fallback_model: "claude-haiku-4-5",
request_id: "req_1",
api_refusal_category: "cyber",
content: "banner",
uuid: randomUUID(),
session_id: options.sessionId,
};
yield refusalFallback(options);
yield assistantText(options, "Done.");
yield result(options);
yield idleState(options);
@@ -1763,6 +1750,28 @@ function idleState(options: Options) {
};
}
/**
* Claude Code's notice that it retried a refused response on the fallback
* model. `retracted` names the messages of that response, which it took back.
*/
function refusalFallback(options: Options, retracted?: string[]) {
return {
type: "system",
subtype: "model_refusal_fallback",
trigger: "refusal",
direction: "retry",
scope: "session",
original_model: "claude-opus-4-8",
fallback_model: "claude-haiku-4-5",
request_id: "req_1",
api_refusal_category: "cyber",
...(retracted && { retracted_message_uuids: retracted }),
content: "banner",
uuid: randomUUID(),
session_id: options.sessionId,
};
}
function assistantText(options: Options, text: string, id = "msg_answer") {
return {
type: "assistant",
@@ -3831,6 +3840,57 @@ describe("ACP v2 prompts", () => {
expect(turnTrace(client.sessionUpdates).at(-1)).toBe("idle end_turn");
});
/**
* Scenario:
* 1. Claude's response calls Read, and the model then refuses.
* 2. Claude Code takes the response back, stops the call without a result,
* and retries on the fallback model. Its notice, or the first message of
* the retry, names the message that it took back.
* Expected:
* - The call ends `cancelled` when it is named, before the retry's answer.
* Nothing else ends a call that ran: it would stay open past the idle.
*/
it.each<[string, { notice: boolean; retry: boolean }]>([
["the fallback notice", { notice: true, retry: false }],
["the first message of the retry", { notice: false, retry: true }],
])(
"ends the tool call of a response that Claude Code took back, named by %s",
async (_name, names) => {
scriptTurns(async function* (options) {
const refused = toolUseMessage(options, "toolu_refused", "Read", { file_path: "/a.log" });
yield refused;
yield refusalFallback(options, names.notice ? [refused.uuid] : undefined);
yield {
...assistantText(options, "The logs are clean."),
...(names.retry && { supersedes: [refused.uuid] }),
};
yield result(options);
});
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
await client.authUpdate(1);
});
const updates = client.sessionUpdates.map(({ update }) => update);
const closed = updates.findIndex(
(update) =>
update.sessionUpdate === "tool_call_update" &&
update.toolCallId === "toolu_refused" &&
update.status === "cancelled",
);
expect(JSON.stringify(updates[closed])).toContain(
"Claude Code replaced the response that made this tool call",
);
expect(closed).toBeLessThan(
updates.findIndex((update) => update.sessionUpdate === "agent_message_chunk"),
);
},
);
it("reports a turn that fails after it was taken in as idle with the error stop reason", async () => {
scriptTurns(async function* (options) {
yield result(options, { is_error: true, result: "API Error: 529 Overloaded" });

View File

@@ -686,21 +686,31 @@ export class AcpToolCallRenderer {
* client `failed`, since v1 has no `cancelled`. If Claude Code ran it but
* returned no result, it failed, unless a `session/cancel` ended the turn
* (`cancelled`): the cancel stopped the run, so a v2 client gets `cancelled`
* for it too. The report does not end the call's terminal: see {@link terminalExit}.
* for it too. The same goes for a call that Claude Code stopped when it took
* back the message that made it (`retracted`, see `RetractedToolCalls`),
* which closes when that is known, before the turn ends. The report does not
* end the call's terminal: see {@link terminalExit}.
*/
unfinished(call: { toolCallId: string; ran: boolean; cancelled?: boolean }): ToolCallUpdate {
const stopped = !call.ran || call.cancelled === true;
unfinished(call: {
toolCallId: string;
ran: boolean;
cancelled?: boolean;
retracted?: boolean;
}): ToolCallUpdate {
const stopped = !call.ran || call.cancelled === true || call.retracted === true;
return {
sessionUpdate: "tool_call_update",
toolCallId: call.toolCallId,
status: stopped && this.capabilities.v2 ? V2_CANCELLED : "failed",
content: [
textContent(
!call.ran
? "Claude stopped this tool call before it ran."
: call.cancelled
? "The turn was cancelled before this tool call returned a result."
: "Claude ended the turn without returning a result for this tool.",
call.retracted
? "Claude Code replaced the response that made this tool call, and stopped the call. If it had started, some of its effects may have happened."
: !call.ran
? "Claude stopped this tool call before it ran."
: call.cancelled
? "The turn was cancelled before this tool call returned a result."
: "Claude ended the turn without returning a result for this tool.",
),
],
};

View File

@@ -0,0 +1,68 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
/** The most assistant messages whose tool uses are kept. The oldest are dropped. */
const MAX_MESSAGES = 1000;
/**
* The tool uses of the assistant messages that Claude Code sent, by message
* `uuid`, to find the tool calls of a message that Claude Code takes back.
*
* Claude Code takes back the response of a model that refuses, and retries on
* the fallback model. It stops the tool calls of that response that have no
* result yet, and sends no result for them. It names the messages it took
* back, by the `uuid` they were sent with:
* - in `retracted_message_uuids` of the `model_refusal_fallback` notice;
* - in `supersedes` of the first assistant message of the retry.
*
* Both name the same messages, so a tool call is given once. Since Claude
* Code 2.1.323 `supersedes` also names a response whose tool call could not
* be parsed, which Claude Code retries too. Such a response holds no tool
* use: its stop reason is `tool_use` and it has no `tool_use` block.
*/
export class RetractedToolCalls {
private readonly toolUses = new Map<string, string[]>();
/** Records the tool uses of a complete assistant message. */
sent(message: SDKMessage): void {
if (message.type !== "assistant" || typeof message.uuid !== "string") return;
const ids = toolUseIds(message);
if (ids.length === 0) return;
this.toolUses.delete(message.uuid);
this.toolUses.set(message.uuid, ids);
if (this.toolUses.size > MAX_MESSAGES) {
this.toolUses.delete(this.toolUses.keys().next().value!);
}
}
/**
* The tool calls of the messages that `message` takes back, each given
* once. A tool call that `message` makes itself is not among them: the
* message that replaces another can hold the same tool use.
*/
retractedBy(message: SDKMessage): string[] {
const own = new Set(toolUseIds(message));
const retracted: string[] = [];
for (const uuid of retractedMessageUuids(message)) {
for (const id of this.toolUses.get(uuid) ?? []) {
if (!own.has(id)) retracted.push(id);
}
this.toolUses.delete(uuid);
}
return retracted;
}
}
/** The `uuid`s of the messages that `message` says Claude Code took back. */
function retractedMessageUuids(message: SDKMessage): readonly string[] {
if (message.type === "assistant") return message.supersedes ?? [];
if (message.type === "system" && message.subtype === "model_refusal_fallback") {
return message.retracted_message_uuids ?? [];
}
return [];
}
/** The ids of the tool uses of an assistant message. */
function toolUseIds(message: SDKMessage): string[] {
if (message.type !== "assistant" || !Array.isArray(message.message.content)) return [];
return message.message.content.flatMap((block) => (block.type === "tool_use" ? [block.id] : []));
}

View File

@@ -330,6 +330,7 @@ import { previewPatchContent } from "../diff.js";
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
import { ChangedMetaClient, ClientConnection } from "./client-connection.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { RetractedToolCalls } from "../tool-calls/retracted.js";
import { ClientCapabilities as ToolCallClientCapabilities } from "../tool-calls/client-capabilities.js";
import {
AcpToolCallRenderer,
@@ -1172,6 +1173,10 @@ export type Session = {
* `tool_call_update` resends only the fields that changed. Created lazily
* by {@link toolCallFieldsOf}. */
toolCallFields?: ToolCallFieldTracker;
/** The tool uses of the assistant messages, to end the tool calls of a
* message that Claude Code takes back. Created lazily by
* {@link retractedToolCallsOf}. */
retractedToolCalls?: RetractedToolCalls;
/** ACP session affinity for calls emitted eagerly by permission handling. */
eagerToolCallSessions?: Map<string, string>;
/** ExitPlanMode denial that intentionally interrupts the current Claude
@@ -3939,19 +3944,23 @@ export class ClaudeAcpAgent {
parentToolUseId !== null && runsInBackground(parentToolUseId);
/** Ends the open tool calls that `include` accepts, with the report of a call
* that a cancel stopped and sent no result for. `include` gets the Agent/Task
* call of the subagent that made the tool call, if a subagent did. */
* that was stopped and sent no result: by a cancel, or (`stoppedBy`
* `retracted`) by Claude Code when it took back the message that made the
* call. `include` gets the Agent/Task call of the subagent that made the
* tool call, if a subagent did, and the tool call. */
const closeOpenToolCalls = async (
include: (parentToolUseId: string | undefined) => boolean,
include: (parentToolUseId: string | undefined, toolCallId: string) => boolean,
stoppedBy: "cancelled" | "retracted" = "cancelled",
): Promise<void> => {
const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
for (const toolCallId of [...session.emittedToolCalls]) {
const parentToolUseId = session.toolCallParents?.get(toolCallId);
if (!include(parentToolUseId)) continue;
if (!include(parentToolUseId, toolCallId)) continue;
const report = renderer.unfinished({
toolCallId,
ran: session.dispatchedToolCalls?.has(toolCallId) === true,
cancelled: true,
cancelled: stoppedBy === "cancelled",
retracted: stoppedBy === "retracted",
});
// Named as the call's other updates are: the subagent runtime routes the
// report with them, to the child session, or to none that has ended.
@@ -3960,6 +3969,14 @@ export class ClaudeAcpAgent {
}
};
/** Ends the open tool calls of the messages that `message` says Claude
* Code took back (see `RetractedToolCalls`). No result comes for them. */
const closeRetractedToolCalls = async (message: SDKMessage): Promise<void> => {
const retracted = new Set(retractedToolCallsOf(session).retractedBy(message));
if (retracted.size === 0) return;
await closeOpenToolCalls((_parent, toolCallId) => retracted.has(toolCallId), "retracted");
};
/** Whether the closing at the end of the work a cancel stopped takes a tool
* call that the subagent started by the Agent call `parentToolUseId` made
* (undefined for a call of the main conversation): not one of a running
@@ -5384,6 +5401,7 @@ export class ClaudeAcpAgent {
// state sync the client's model picker (and the model-dependent
// options rebuilt from it) keeps advertising a model the
// session is no longer running (see `modelRefusalFallback`).
await closeRetractedToolCalls(message);
const fallback = modelRefusalFallback(message);
const { summary: fallbackSummary, explanation } = fallback;
// A silent model swap is a session-level advisory, not something the model said.
@@ -6377,6 +6395,8 @@ export class ClaudeAcpAgent {
case "user":
case "assistant": {
if (message.type === "assistant") {
await closeRetractedToolCalls(message);
retractedToolCallsOf(session).sent(message);
recordDispatchedToolUses(session, message.message.content);
// A tool use that did not stream is reached here first.
const content = message.message.content;
@@ -10943,6 +10963,13 @@ function toolCallFieldsOf(session: {
return (session.toolCallFields ??= new ToolCallFieldTracker());
}
/** The record of a session's assistant tool uses, created on first use. */
function retractedToolCallsOf(session: {
retractedToolCalls?: RetractedToolCalls;
}): RetractedToolCalls {
return (session.retractedToolCalls ??= new RetractedToolCalls());
}
const MAX_RESOLVED_TOOL_NAMES = 1000;
/** Keeps the tool names of the tool results in `content` before the tool use cache drops them. */

View File

@@ -1008,6 +1008,7 @@ export class V2Session implements RequestTarget {
return;
case "model_refusal_fallback": {
// Claude Code retried a refused turn on the fallback model.
this.output.closeRetracted(message);
const fallback = modelRefusalFallback(message);
this.output.emitV1([
{ sessionId: this.sessionId, update: noticeUpdate(fallback.notice) },

View File

@@ -16,6 +16,7 @@ import type { ContentBlock } from "@agentclientprotocol/sdk/unstable";
import type {
SDKAssistantMessage,
SDKMemoryRecallMessage,
SDKMessage,
SDKPartialAssistantMessage,
SDKPermissionDeniedMessage,
SDKToolProgressMessage,
@@ -40,6 +41,7 @@ import type { AcpSessionNotification } from "../acp-subagents.js";
import { isCompactSummaryMessage, replayCompactSummary } from "../context-compaction.js";
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { RetractedToolCalls } from "../tool-calls/retracted.js";
import { AcpToolCallRenderer, progressToolCallId, V2_CANCELLED } from "../tool-calls/renderer.js";
import { changedTaskPlanEntries, unregisterHookCallback, type TaskState } from "../tools.js";
import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js";
@@ -100,6 +102,8 @@ export class SessionOutput {
private replayCleared: Set<string> | undefined;
/** The open tool calls whose tool use reached a complete assistant message: Claude Code runs them. */
private readonly dispatched = new Set<string>();
/** The tool uses of the assistant messages, for {@link closeRetracted}. */
private readonly retracted = new RetractedToolCalls();
/**
* Whether the output comes from work that a `session/cancel` stopped, until
* its idle. The session's streamed deltas still go out,
@@ -185,6 +189,8 @@ export class SessionOutput {
* cancel, the stopped work's message is dropped (see {@link cancelled}).
*/
assistant(message: SDKAssistantMessage): void {
this.closeRetracted(message);
this.retracted.sent(message);
const messageId = messageIdForGrouping(message);
for (const block of message.message.content) {
if (block.type === "tool_use") this.dispatched.add(block.id);
@@ -564,22 +570,44 @@ export class SessionOutput {
* The calls in `keep` stay open, such as one whose task runs in the background.
*/
closeUnfinished(stopped: boolean, keep: ReadonlySet<string> = new Set()): void {
const renderer = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES);
for (const toolCallId of [...this.unfinished]) {
if (keep.has(toolCallId)) continue;
const ran = this.dispatched.has(toolCallId);
if (ran && !stopped) continue;
const report = renderer.unfinished({ toolCallId, ran, cancelled: stopped });
const toolUse = this.toolUseCache[toolCallId];
const terminalExit = toolUse && renderer.terminalExit(toolUse);
if (terminalExit) report._meta = { ...report._meta, ...terminalExit };
this.emittedToolCalls.delete(toolCallId);
delete this.toolUseCache[toolCallId];
this.toolCallFields.delete(toolCallId);
this.emit([{ sessionId: this.sessionId, update: report }]);
this.close({ toolCallId, ran, cancelled: stopped });
}
}
/**
* Ends the open tool calls of the messages that `message` says Claude Code
* took back (see `RetractedToolCalls`), `cancelled`: no result comes for
* them, and a call that ran stays open at the end of the work otherwise.
*/
closeRetracted(message: SDKMessage): void {
for (const toolCallId of this.retracted.retractedBy(message)) {
if (this.unfinished.has(toolCallId)) {
this.close({ toolCallId, ran: this.dispatched.has(toolCallId), retracted: true });
}
}
}
/**
* Ends the open tool call of `call` without its result, with the report of
* `AcpToolCallRenderer.unfinished`. A command's report also ends its
* terminal. The call is forgotten, so a late result changes nothing.
*/
private close(call: Parameters<AcpToolCallRenderer["unfinished"]>[0]): void {
const renderer = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES);
const report = renderer.unfinished(call);
const toolUse = this.toolUseCache[call.toolCallId];
const terminalExit = toolUse && renderer.terminalExit(toolUse);
if (terminalExit) report._meta = { ...report._meta, ...terminalExit };
this.emittedToolCalls.delete(call.toolCallId);
delete this.toolUseCache[call.toolCallId];
this.toolCallFields.delete(call.toolCallId);
this.emit([{ sessionId: this.sessionId, update: report }]);
}
/** The tool calls that the client holds without a final status. */
private readonly unfinished = new Set<string>();