Compare commits

...

2 Commits

Author SHA1 Message Date
Maksim Zheltoukhov
fe8b69edd4 feat: accept IDE session hooks without dropping model settings (#1265)
IDEs can pass their own Claude hooks through
`_meta.claudeCode.options.settings.hooks` on `session/new` and
`session/load`. Before this change, any session settings replaced
`CLAUDE_MODEL_CONFIG` entirely, so adding a single hook dropped the
deployment's `modelOverrides` / `availableModels`. The two are now
merged, and the session settings win per key.

The wrapper does not merge hooks itself: Claude Code already runs the
hooks of every settings tier, so user and project hooks keep running
next to the session ones. Hooks stay scoped to their session, and a
`session/load` with different settings recreates the query, which
replaces or removes them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:00:07 +02:00
Ben Brandt
b3bed2f763 fix: report a command whose result says interrupted as cancelled on the experimental ACP v2 surface (#1301)
#1289 made a v2 client get `cancelled` for a run that Claude Code
aborted, when Claude Code reports it as an error result with
`non_execution_kind`. Claude Code has a second way to report an aborted
command: a **successful** Bash result whose structured output says
`interrupted: true`. A v2 client got `completed` for that. With this PR
it gets `cancelled` too, so the rule is the same however Claude Code
reports the abort.

## When Claude Code sends that result

I read the CLI that SDK 0.3.293 bundles. I didn't reproduce these cases
live.

| What stops the command | What the adapter receives | v2 before | v2
after |
| --- | --- | --- | --- |
| The client cancels (`session/cancel`) | an error result,
`non_execution_kind: "interrupted"` | `cancelled` (#1289) | same |
| A timeout | an error, "Command timed out after …", **not**
`interrupted` (or the command is backgrounded) | `failed` | same |
| The output limit or a replaced output file | an error with `Exit code
137`, not `interrupted` | `failed` | same |
| A `now`-priority message such as `_session/steering`, landing just
before the command starts, or while the output limit kills it | a
successful result, `interrupted: true` | `completed` | **`cancelled`** |

A successful `interrupted` result needs the abort reason `interrupt`,
which a `now`-priority message causes. That abort doesn't kill a running
command, so this result only comes from the races in the last row.
Clients will rarely see it. The point of the change is that v2's status
follows what Claude Code reports, however it reports it.

v1 and AIR clients are unchanged: `completed` with no exit code, and AIR
still gets exit code 1.

## How

- `AcpToolCallRenderer.resultStatus` takes the command's `interrupted`
fact alongside `nonExecution`.
- `result()` used to call `resultFields`, which drops the command facts.
It now computes the facts once (`resultFacts`) and builds the fields
from them (`fieldsOfResult`). That lets it pass `interrupted` without
adding a field to `RenderedResult`, which the public
`toolUpdateFromToolResult` returns.
- The agent's checks for a finished tool call already count `cancelled`
(`isFinalToolCallStatus`, #1289).

## Checks

- `npm run check`, `npm run build` and all 2730 tests pass. No golden
file changes: no recorded scenario has an interrupted command.
- New tests:
- **renderer:** an interrupted command on v2, v1 and AIR, and one that
isn't interrupted on v2;
- **v2 surface:** a v2 client receives `cancelled`, and the turn still
ends `end_turn`.
- I made four mutations and each failed a test: the rule ignores
`interrupted`, every client gets `cancelled`, `result()` doesn't pass
the fact, and `result()` inverts it.
2026-10-12 12:15:06 +02:00
8 changed files with 420 additions and 38 deletions

View File

@@ -17,6 +17,7 @@ It describes only this adapter.
- [Claude tools and ACP fields](#claude-tools-and-acp-fields)
- [Diff patch](#diff-patch)
- [Plan file](#plan-file)
- [Session hooks](#session-hooks)
- [Permission presentation](#permission-presentation)
- [Goal](#goal)
- [Recommended config values](#recommended-config-values)
@@ -496,6 +497,21 @@ The plan text goes out as before in these cases:
AIR then shows the in-memory `plan` text.
A client that is not AIR always gets the whole SDK input, like upstream.
## Session hooks
The adapter accepts session hooks from any client, without a capability.
AIR puts the hooks into `_meta.claudeCode.options.settings.hooks` of `session/new` and `session/load`:
```json
{ "claudeCode": { "options": { "settings": { "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }] } } } }
```
- The settings go to the flag settings tier of that session only. Other sessions and the settings files do not change.
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/session-hooks.test.ts` checks this with the Claude Code binary.
- `CLAUDE_MODEL_CONFIG` stays in effect (see [Model configuration](model-configuration.md#precedence)).
- A `session/load` with different settings recreates the query, so it applies the new hooks.
- Claude asks for no hook trust step.
## Permission presentation
The adapter uses standard ACP permission requests and responses.

View File

@@ -42,12 +42,12 @@ node dist/index.js
## Precedence
When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself.
An ACP caller can provide `settings` via `_meta.claudeCode.options.settings` in `session/new` or `session/load`, as an object or a path to a settings file. The adapter merges `CLAUDE_MODEL_CONFIG` into those settings, so a caller that only adds hooks keeps the model settings. When both set the same key, the caller wins; the merge is shallow, so a caller's `modelOverrides` replaces the env var's map.
| Source | Priority |
| -------------------------------------------- | ----------------------------------------------------- |
| `_meta.claudeCode.options.settings` (caller) | Highest — used if present |
| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings |
| Source | Priority |
| -------------------------------------------- | ---------------------------------------------- |
| `_meta.claudeCode.options.settings` (caller) | Highest — wins for each key it sets |
| `CLAUDE_MODEL_CONFIG` (env var) | Used for the keys that the caller does not set |
## Format details

View File

@@ -9714,32 +9714,26 @@ export class ClaudeAcpAgent {
// into the query.
const resolvedProvider = this.resolveProviderConfig();
const providerEnv = createEnvForProvider(resolvedProvider);
const configuredSettings =
userProvidedOptions?.settings ??
(modelConfig
? {
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
}
: undefined);
const configuredSettingsObject =
typeof configuredSettings === "string"
const providedSettings = userProvidedOptions?.settings;
const providedSettingsObject =
typeof providedSettings === "string"
? (JSON.parse(
await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"),
await fs.readFile(path.resolve(params.cwd, providedSettings), "utf8"),
) as Settings)
: configuredSettings;
: providedSettings;
const configuredSettingsObject = modelConfig
? { ...modelConfig, ...providedSettingsObject }
: providedSettingsObject;
// Claude Code applies env from settings.json after the subprocess env. Put
// an active ACP route in the programmatic settings tier too so user/project
// settings cannot silently restore a different ANTHROPIC_BASE_URL.
let settings = configuredSettings;
if (resolvedProvider) {
const baseSettings = configuredSettingsObject;
settings = {
...baseSettings,
apiKeyHelper: "",
env: { ...baseSettings?.env, ...providerEnv },
};
}
const settings = resolvedProvider
? {
...configuredSettingsObject,
apiKeyHelper: "",
env: { ...configuredSettingsObject?.env, ...providerEnv },
}
: configuredSettingsObject;
const env = {
...process.env,
...userProvidedOptions?.env,

View File

@@ -1279,6 +1279,69 @@ describe("ACP v2 prompts", () => {
expect(statuses.at(-1)).toBe("cancelled");
});
it("reports a command whose result says interrupted as cancelled", async () => {
scriptTurns(async function* (options) {
yield {
type: "assistant",
message: {
id: "msg_bash",
type: "message",
role: "assistant",
model: "claude-sonnet-4-6",
content: [
{ type: "tool_use", id: "toolu_bash", name: "Bash", input: { command: "make" } },
],
stop_reason: "tool_use",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId,
};
// Claude Code aborted the command for a steering message: a successful
// result whose structured output says `interrupted`.
yield {
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_bash",
content:
"Command aborted before execution<error>Command was aborted before completion</error>",
},
],
},
tool_use_result: {
stdout: "",
stderr: "Command aborted before execution",
interrupted: true,
isImage: false,
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId,
};
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 statuses = client
.updates("tool_call_update")
.filter((update) => update.toolCallId === "toolu_bash" && update.status)
.map((update) => update.status);
expect(statuses.at(-1)).toBe("cancelled");
});
it("reports a tool call that Claude dropped before it ran as cancelled", async () => {
scriptTurns(async function* (options) {
// Claude starts a tool_use, but no complete assistant message holds it,

View File

@@ -656,9 +656,71 @@ describe("createSession options merging", () => {
expect(capturedOptions!.settings).toBeUndefined();
});
it("ignores env var when _meta provides settings", async () => {
it("keeps model settings when session settings add hooks", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
availableModels: ["opus"],
});
const hooks = { PostToolUse: [{ hooks: [{ type: "command", command: "echo ready" }] }] };
await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: { claudeCode: { options: { settings: { hooks } } } },
});
expect(capturedOptions!.settings).toEqual({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
availableModels: ["opus"],
hooks,
});
// The CLI adds these hooks to the ones in the user and project settings.
expect(capturedOptions!.settingSources).toEqual(["user", "project", "local"]);
});
it("applies session hooks on session/load with model settings", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
const cwd = process.cwd();
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
const hooks = { Stop: [{ hooks: [{ type: "command", command: "echo done" }] }] };
await agent.loadSession({
sessionId,
cwd,
mcpServers: [],
_meta: { claudeCode: { options: { settings: { hooks } } } },
});
expect(capturedOptions!.resume).toBe(sessionId);
expect(capturedOptions!.settings).toEqual({ availableModels: ["opus"], hooks });
});
it("merges model settings with a session settings file", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
const settingsDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-settings-"));
const settingsFile = path.join(settingsDir, "settings.json");
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { Stop: [] } }));
try {
await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: { claudeCode: { options: { settings: settingsFile } } },
});
expect(capturedOptions!.settings).toEqual({
availableModels: ["opus"],
hooks: { Stop: [] },
});
} finally {
fs.rmSync(settingsDir, { recursive: true });
}
});
it("prefers session settings when both sources set the same field", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
availableModels: ["opus"],
});
await agent.newSession({
@@ -676,10 +738,10 @@ describe("createSession options merging", () => {
},
});
// _meta settings take precedence; env var is ignored entirely
expect(capturedOptions!.settings).toEqual({
model: "claude-sonnet-4-6",
modelOverrides: { "claude-opus-4-6": "meta-value" },
availableModels: ["opus"],
});
});

View File

@@ -0,0 +1,202 @@
/**
* Session hooks against the real Claude Code binary. A local stub of the
* Messages API answers every turn, so the test needs no network or account.
* The CLI merges the settings tiers, so a mock SDK cannot show that the user
* and project hooks still run next to the hooks of `_meta.claudeCode.options`.
*/
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import type { NewSessionRequest } from "@agentclientprotocol/sdk";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
function sse(type: string, data: object): string {
return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
}
const REPLY =
sse("message_start", {
message: {
id: "msg_stub",
type: "message",
role: "assistant",
model: "claude-sonnet-4-5",
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 0 },
},
}) +
sse("content_block_start", { index: 0, content_block: { type: "text", text: "" } }) +
sse("content_block_delta", { index: 0, delta: { type: "text_delta", text: "ok" } }) +
sse("content_block_stop", { index: 0 }) +
sse("message_delta", {
delta: { stop_reason: "end_turn", stop_sequence: null },
usage: { output_tokens: 1 },
}) +
sse("message_stop", {});
describe("session hooks with the Claude Code binary", () => {
let root: string;
let cwd: string;
let home: string;
let log: string;
let server: http.Server;
let agent: ClaudeAcpAgent;
let env: Record<string, string | undefined>;
let settingsFiles: Record<string, string>;
let logCursor = 0;
/** A SessionStart hook that writes `tag` to the log. */
function hooks(tag: string) {
return {
SessionStart: [{ hooks: [{ type: "command", command: `echo ${tag} >> "${log}"` }] }],
};
}
function meta(tag?: string): NewSessionRequest["_meta"] {
return { claudeCode: { options: { env, ...(tag && { settings: { hooks: hooks(tag) } }) } } };
}
/** The hooks that ran since the previous call. Hooks of one event run in parallel. */
async function hooksSinceLastCheck(): Promise<string[]> {
const lines = (await readFile(log, "utf8").catch(() => "")).split("\n").filter(Boolean);
const added = lines.slice(logCursor);
logCursor = lines.length;
return added.sort();
}
/** A turn finishes after the SessionStart hooks, and it persists the session for a load. */
async function finishTurn(sessionId: string): Promise<void> {
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
expect(response.stopReason).toBe("end_turn");
}
async function readSettingsFiles(): Promise<Record<string, string>> {
const files: Record<string, string> = {};
for (const dir of [path.join(home, ".claude"), path.join(cwd, ".claude")]) {
for (const name of await readdir(dir)) {
if (name.startsWith("settings")) {
files[path.join(dir, name)] = await readFile(path.join(dir, name), "utf8");
}
}
}
return files;
}
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), "claude-acp-session-hooks-"));
cwd = path.join(root, "workspace");
home = path.join(root, "home");
log = path.join(root, "hooks.log");
await mkdir(path.join(cwd, ".claude"), { recursive: true });
await mkdir(path.join(home, ".claude"), { recursive: true });
await writeFile(
path.join(home, ".claude", "settings.json"),
JSON.stringify({ hooks: hooks("user") }),
);
await writeFile(
path.join(cwd, ".claude", "settings.json"),
JSON.stringify({ hooks: hooks("project") }),
);
settingsFiles = await readSettingsFiles();
// vitest.config.ts points every test at `/usr/bin/false` in place of the
// CLI. This file opts in to the real binary, and isolates the agent process
// too: it reads user settings and runs `claude auth status` itself.
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", undefined);
vi.stubEnv("HOME", home);
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(home, ".claude"));
server = http.createServer((req, res) => {
req.resume();
req.on("end", () => {
if (req.url?.startsWith("/v1/messages/count_tokens")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ input_tokens: 1 }));
} else if (req.url?.startsWith("/v1/messages")) {
res.writeHead(200, { "content-type": "text/event-stream" });
res.end(REPLY);
} else {
res.writeHead(200);
res.end();
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
env = {
...process.env,
HOME: home,
CLAUDE_CONFIG_DIR: path.join(home, ".claude"),
ANTHROPIC_API_KEY: "sk-ant-test",
ANTHROPIC_BASE_URL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
};
const client = {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} as unknown as AcpClient;
agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
});
afterAll(async () => {
await agent?.dispose();
vi.unstubAllEnvs();
await new Promise((resolve) => server?.close(resolve));
// The CLI may still write the transcript of the last session after dispose.
if (root) await rm(root, { recursive: true, force: true, maxRetries: 10 });
});
// With CLAUDE_MODEL_CONFIG, the adapter passes the merged settings inline.
for (const modelConfig of [
undefined,
'{"modelOverrides":{"claude-opus-4-6":"claude-opus-4-6"}}',
]) {
describe(modelConfig ? "with CLAUDE_MODEL_CONFIG" : "without CLAUDE_MODEL_CONFIG", () => {
const original = process.env.CLAUDE_MODEL_CONFIG;
beforeEach(() => {
if (modelConfig) process.env.CLAUDE_MODEL_CONFIG = modelConfig;
else delete process.env.CLAUDE_MODEL_CONFIG;
});
afterEach(() => {
if (original === undefined) delete process.env.CLAUDE_MODEL_CONFIG;
else process.env.CLAUDE_MODEL_CONFIG = original;
});
it("runs the user, project and IDE hooks of one event, only in their session", async () => {
const withHooks = await agent.newSession({ cwd, mcpServers: [], _meta: meta("ide") });
await finishTurn(withHooks.sessionId);
expect(await hooksSinceLastCheck()).toEqual(["ide", "project", "user"]);
const withoutHooks = await agent.newSession({ cwd, mcpServers: [], _meta: meta() });
await finishTurn(withoutHooks.sessionId);
expect(await hooksSinceLastCheck()).toEqual(["project", "user"]);
expect(await readSettingsFiles()).toEqual(settingsFiles);
}, 60_000);
it("replaces and removes IDE hooks on session/load", async () => {
const { sessionId } = await agent.newSession({
cwd,
mcpServers: [],
_meta: meta("ide-old"),
});
await finishTurn(sessionId);
expect(await hooksSinceLastCheck()).toEqual(["ide-old", "project", "user"]);
await agent.loadSession({ sessionId, cwd, mcpServers: [], _meta: meta("ide-new") });
await finishTurn(sessionId);
expect(await hooksSinceLastCheck()).toEqual(["ide-new", "project", "user"]);
await agent.loadSession({ sessionId, cwd, mcpServers: [], _meta: meta() });
await finishTurn(sessionId);
expect(await hooksSinceLastCheck()).toEqual(["project", "user"]);
expect(await readSettingsFiles()).toEqual(settingsFiles);
}, 60_000);
});
}
});

View File

@@ -3820,6 +3820,31 @@ describe("tool_result_meta non-execution stamping", () => {
});
});
it("reports an interrupted command as cancelled to a v2 client, and as completed to every other client", () => {
const status = (interrupted: boolean, toolCallCapabilities?: ToolCallCapabilities) =>
toAcpNotifications(
[{ type: "tool_result", tool_use_id: "toolu_bash", content: "partial" }] as any,
"user",
"test-session",
{ toolu_bash: bashToolUse },
mockClient,
mockLogger,
{
toolUseResult: { stdout: "partial", stderr: "", interrupted, isImage: false },
...(toolCallCapabilities ? { toolCallCapabilities } : {}),
},
).at(-1)?.update;
const v2 = ToolCallCapabilities.from({}, { v2: true });
expect(status(true, v2)).toMatchObject({ status: "cancelled" });
expect(status(false, v2)).toMatchObject({ status: "completed" });
// v1 has no cancelled status.
expect(status(true)).toMatchObject({ status: "completed" });
expect(status(true, ToolCallCapabilities.from(AIR_CLIENT))).toMatchObject({
status: "completed",
});
});
it("closes a tool call that Claude dropped before it ran as cancelled on v2, and as failed elsewhere", () => {
const unfinished = (ran: boolean, capabilities?: ToolCallCapabilities) =>
new AcpToolCallRenderer(capabilities).unfinished({ toolCallId: "toolu_bash", ran });

View File

@@ -151,14 +151,22 @@ export class AcpToolCallRenderer {
}
/**
* The status of a tool result. `nonExecution` is why Claude Code stamped an
* error result instead of the tool's output. A run that was aborted is
* `cancelled` for a v2 client, which has that status; every other client
* gets `failed`, with the kind in `_meta.claudeCode.nonExecutionKind`.
* The status of a tool result. Claude Code reports a run that it aborted in
* one of two ways: an error result whose `nonExecution` kind says so (a
* cancelled turn), or a command result that says `interrupted` (for example,
* a steering message landed just before the command started). Such a run is
* `cancelled` for a v2 client, which has that status. Every other client gets
* `failed` for the error, with the kind in `_meta.claudeCode.nonExecutionKind`,
* and `completed` for the command.
*/
resultStatus(isError: boolean, nonExecution: ToolResultMeta | undefined): ToolCallStatus {
if (!isError) return "completed";
return this.capabilities.v2 && isAbortedToolResult(nonExecution) ? V2_CANCELLED : "failed";
resultStatus(
isError: boolean,
nonExecution: ToolResultMeta | undefined,
interrupted = false,
): ToolCallStatus {
const aborted = interrupted || (isError && isAbortedToolResult(nonExecution));
if (this.capabilities.v2 && aborted) return V2_CANCELLED;
return isError ? "failed" : "completed";
}
/** The facts of a tool use. */
@@ -332,7 +340,14 @@ export class AcpToolCallRenderer {
/** The result fields of a tool result, before the status and the tool name. */
resultFields(toolUse: ToolUse, result: ResultBlock, structured?: unknown): RenderedResult {
const facts = this.resultFacts(toolUse, result, structured);
return this.fieldsOfResult(toolUse, result, this.resultFacts(toolUse, result, structured));
}
private fieldsOfResult(
toolUse: ToolUse,
result: ResultBlock,
facts: ToolResultFacts,
): RenderedResult {
const { command, ...fields } = facts;
if (!command) return fields;
// The terminal was announced under the tool_use id (see `toolCall`), so the
@@ -397,7 +412,8 @@ export class AcpToolCallRenderer {
result: ResultBlock,
options: { structured?: unknown; nonExecution?: ToolResultMeta } = {},
): ToolCallUpdate[] {
const { _meta: resultMeta, ...fields } = this.resultFields(toolUse, result, options.structured);
const facts = this.resultFacts(toolUse, result, options.structured);
const { _meta: resultMeta, ...fields } = this.fieldsOfResult(toolUse, result, facts);
// AIR shows a read or a search that names a path as the list of viewed
// files. That view does not show the text of the result, so it stays out.
const viewedFiles =
@@ -445,7 +461,11 @@ export class AcpToolCallRenderer {
} satisfies ToolUpdateMeta,
toolCallId: toolUse.id,
sessionUpdate: "tool_call_update",
status: this.resultStatus(result.is_error === true, options.nonExecution),
status: this.resultStatus(
result.is_error === true,
options.nonExecution,
facts.command?.interrupted === true,
),
...(rawOutput !== undefined ? { rawOutput } : {}),
...(planFilePath ? { rawInput: planFileInput(toolUse.input, planFilePath) } : {}),
...fields,