mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-13 21:02:08 +00:00
Compare commits
1 Commits
v0.89.2-pr
...
v0.89.2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bed2f763 |
@@ -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,
|
||||
|
||||
@@ -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