mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-15 13:51:13 +00:00
Compare commits
2 Commits
v0.89.2-pr
...
v0.89.2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe8b69edd4 | ||
|
|
b3bed2f763 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
202
src/tests/session-hooks.test.ts
Normal file
202
src/tests/session-hooks.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user