mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 20:01:11 +00:00
Compare commits
1 Commits
v0.89.1
...
v0.89.2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
231bbd7dd6 |
137
src/acp-agent.ts
137
src/acp-agent.ts
@@ -65,6 +65,7 @@ import {
|
||||
FastModeState,
|
||||
getSessionMessages,
|
||||
getSubagentMessages,
|
||||
HookCallback,
|
||||
listSessions,
|
||||
McpServerConfig,
|
||||
McpServerStatus,
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
Options,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
PreToolUseHookInput,
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessageError,
|
||||
@@ -8505,6 +8507,106 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner of a tool use that Claude Code reports outside the stream, in
|
||||
* `canUseTool` or a hook, for the subagent `agentID` or the root session.
|
||||
* `parentToolUseId` is the Agent/Task tool call that spawned the subagent,
|
||||
* as the streamed subagent path stamps it in `_meta.claudeCode` (see
|
||||
* `liveBackgroundTasks`). `toolCallSessionId` is the session of an eagerly
|
||||
* emitted tool call and of its requests: the subagent's child session once
|
||||
* it is announced, else the root session. In the rare SDK ordering where
|
||||
* the callback beats the spawning Agent/Task frame, the root keeps it; the
|
||||
* later frame announces the child with its correct nested lineage.
|
||||
*/
|
||||
private toolUseOwner(
|
||||
session: Session,
|
||||
sessionId: string,
|
||||
agentID: string | undefined,
|
||||
): { parentToolUseId?: string; toolCallSessionId: string } {
|
||||
const parentToolUseId = agentID
|
||||
? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
|
||||
: undefined;
|
||||
const child =
|
||||
clientSupportsSubagents(this.clientCapabilities) && agentID
|
||||
? session.nativeSubagentsByTaskId?.get(agentID)
|
||||
: undefined;
|
||||
return {
|
||||
...(parentToolUseId ? { parentToolUseId } : {}),
|
||||
toolCallSessionId: child?.announced ? child.sessionId : sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The PreToolUse hook of a v2 connection, for Edit and Write: the tool call
|
||||
* shows the exact patch of the change before the tool runs.
|
||||
*
|
||||
* A v2 diff cannot hold the input's snippet, so without it an auto-approved
|
||||
* Edit or Write would show its change only once it is done. Claude Code runs
|
||||
* the tool only after the hook returns, so the file is as it was before the
|
||||
* change, as for the approval patch of a permission request (which runs
|
||||
* after this hook, and shows the same patch). The hook makes no permission
|
||||
* decision, and a failure only leaves the preview out.
|
||||
*/
|
||||
private fileChangePreviewHook(sessionId: string): HookCallback {
|
||||
return async (input, toolUseID) => {
|
||||
if (input.hook_event_name === "PreToolUse" && toolUseID) {
|
||||
await this.previewFileChange(sessionId, input, toolUseID).catch((error) =>
|
||||
this.logger.error(`Session ${sessionId}: failed to preview ${toolUseID}: ${error}`),
|
||||
);
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
}
|
||||
|
||||
private async previewFileChange(
|
||||
sessionId: string,
|
||||
input: PreToolUseHookInput,
|
||||
toolUseID: string,
|
||||
): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return;
|
||||
const toolInput = (input.tool_input ?? {}) as Record<string, unknown>;
|
||||
const content = await previewPatchContent(input.tool_name, toolInput, session.cwd, "v2");
|
||||
if (!content) return;
|
||||
const { parentToolUseId, toolCallSessionId } = this.toolUseOwner(
|
||||
session,
|
||||
sessionId,
|
||||
input.agent_id,
|
||||
);
|
||||
// Hook callbacks bypass the stream, so the hook can come before the
|
||||
// streamed tool_use, as `canUseTool` can: then the tool call goes out
|
||||
// with the patch.
|
||||
if (!session.emittedToolCalls.has(toolUseID)) {
|
||||
await this.ensureToolCallEmitted(
|
||||
sessionId,
|
||||
input.tool_name,
|
||||
toolUseID,
|
||||
toolInput,
|
||||
parentToolUseId,
|
||||
undefined,
|
||||
toolCallSessionId,
|
||||
content,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const update: SessionNotification["update"] = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolUseID,
|
||||
content,
|
||||
};
|
||||
if (parentToolUseId) stampParentToolUseId(update, parentToolUseId);
|
||||
const fields = toolCallFieldsOf(session);
|
||||
if (!fields.apply(update)) return;
|
||||
// The streamed input must not replace the patch, as for an approval patch.
|
||||
fields.pinContent(toolUseID, content);
|
||||
// The stream's router sends it to the session that holds the tool call.
|
||||
if (session.nativeSubagentDeliver) {
|
||||
await session.nativeSubagentDeliver({ sessionId, update });
|
||||
} else {
|
||||
await this.client.sessionUpdate({ sessionId: toolCallSessionId, update });
|
||||
}
|
||||
}
|
||||
|
||||
canUseTool(sessionId: string): CanUseTool {
|
||||
return async (
|
||||
toolName,
|
||||
@@ -8533,25 +8635,11 @@ export class ClaudeAcpAgent {
|
||||
};
|
||||
}
|
||||
|
||||
// When the tool call originates inside a subagent, attribute the eagerly
|
||||
// emitted tool_call (and the permission request itself) to the Agent/Task
|
||||
// tool call that spawned the subagent, mirroring the streamed subagent
|
||||
// path's `_meta.claudeCode.parentToolUseId` (see `liveBackgroundTasks`).
|
||||
const parentToolUseId = agentID
|
||||
? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
|
||||
: undefined;
|
||||
const permissionSessionId =
|
||||
clientSupportsSubagents(this.clientCapabilities) && agentID
|
||||
? (() => {
|
||||
const child = session.nativeSubagentsByTaskId?.get(agentID);
|
||||
// A request must never target a child session before its
|
||||
// subagent_spawned notification. In the rare SDK ordering where
|
||||
// canUseTool beats the spawning Agent/Task frame, keep the
|
||||
// permission on the root session; the later frame will announce
|
||||
// the child with correct nested lineage.
|
||||
return child?.announced ? child.sessionId : sessionId;
|
||||
})()
|
||||
: sessionId;
|
||||
const { parentToolUseId, toolCallSessionId: permissionSessionId } = this.toolUseOwner(
|
||||
session,
|
||||
sessionId,
|
||||
agentID,
|
||||
);
|
||||
if (agentID && !parentToolUseId) {
|
||||
// The attribution rests on task_started.task_id === canUseTool's
|
||||
// agentID for subagent tasks (documented since SDK 0.3.292, which
|
||||
@@ -9726,6 +9814,17 @@ export class ClaudeAcpAgent {
|
||||
tools,
|
||||
hooks: {
|
||||
...userProvidedOptions?.hooks,
|
||||
// On v2, an Edit or Write shows its exact patch before it runs, also
|
||||
// when no permission request shows it (`fileChangePreviewHook`): a v2
|
||||
// diff cannot hold the input's snippet.
|
||||
...(this.v2
|
||||
? {
|
||||
PreToolUse: [
|
||||
...(userProvidedOptions?.hooks?.PreToolUse || []),
|
||||
{ matcher: "Edit|Write", hooks: [this.fileChangePreviewHook(sessionId)] },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
PostToolUse: [
|
||||
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"locations":[{"path":"<cwd>/old.ts"}],"rawInput":{"file_path":"<cwd>/old.ts"},"sessionUpdate":"tool_call_update","title":"Write old.ts","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/old.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/old.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/old.ts <cwd>/old.ts\n--- <cwd>/old.ts\n+++ <cwd>/old.ts\n@@ -1 +1 @@\n-export const x = 0;\n+export const x = 1;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawOutput":"The file <cwd>/old.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write","toolResponse":{"content":"export const x = 1;\n","filePath":"<cwd>/old.ts","originalFile":"export const x = 0;\n","structuredPatch":[{"lines":["-export const x = 0;","+export const x = 1;"],"newLines":1,"newStart":1,"oldLines":1,"oldStart":1}],"type":"update"}}},"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/old.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/old.ts <cwd>/old.ts\n--- <cwd>/old.ts\n+++ <cwd>/old.ts\n@@ -1 +1 @@\n-export const x = 0;\n+export const x = 1;\n"},"type":"diff"}],"locations":[{"line":1,"path":"<cwd>/old.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write","toolResponse":{"content":"export const x = 1;\n","filePath":"<cwd>/old.ts","originalFile":"export const x = 0;\n","structuredPatch":[{"lines":["-export const x = 0;","+export const x = 1;"],"newLines":1,"newStart":1,"oldLines":1,"oldStart":1}],"type":"update"}}},"locations":[{"line":1,"path":"<cwd>/old.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"locations":[{"path":"<cwd>/new.ts"}],"rawInput":{"file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","title":"Write new.ts","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/new.ts"}],"name":"Write","rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"status":"pending","title":"Write new.ts","toolCallId":"toolu_write"},"type":"tool_call"},"title":"Write new.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
|
||||
@@ -98,7 +98,10 @@ type QueryOptions = {
|
||||
input: Record<string, unknown>,
|
||||
extra: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
hooks?: Record<string, { hooks: ((input: any, id: string, opts: any) => Promise<unknown>)[] }[]>;
|
||||
hooks?: Record<
|
||||
string,
|
||||
{ matcher?: string; hooks: ((input: any, id: string, opts: any) => Promise<unknown>)[] }[]
|
||||
>;
|
||||
};
|
||||
|
||||
/** What a scenario script reads and calls. */
|
||||
@@ -116,8 +119,13 @@ export interface ScriptContext {
|
||||
toolUseID: string,
|
||||
extra?: Record<string, unknown>,
|
||||
): Promise<unknown>;
|
||||
/** Calls every hook callback that the agent registered for the event. */
|
||||
/**
|
||||
* Calls the hook callbacks that the agent registered for the event, whose
|
||||
* `matcher`, like Claude Code's, matches the `tool_name` of the input.
|
||||
*/
|
||||
hook(event: string, input: Record<string, unknown>, toolUseID?: string): Promise<void>;
|
||||
/** Calls the PreToolUse hooks of a tool use, which run before it runs. */
|
||||
preToolUse(toolUseID: string, toolName: string, input: unknown): Promise<void>;
|
||||
/** Calls the PostToolUse hooks of a tool use. */
|
||||
postToolUse(
|
||||
toolUseID: string,
|
||||
@@ -194,6 +202,13 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
|
||||
}),
|
||||
async hook(event, input, toolUseID) {
|
||||
for (const matcher of options.hooks?.[event] ?? []) {
|
||||
if (
|
||||
matcher.matcher &&
|
||||
typeof input.tool_name === "string" &&
|
||||
!new RegExp(`^(?:${matcher.matcher})$`).test(input.tool_name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const callback of matcher.hooks) {
|
||||
await callback(
|
||||
{ hook_event_name: event, session_id: sessionId, cwd: script.cwd, ...input },
|
||||
@@ -203,6 +218,13 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
|
||||
}
|
||||
}
|
||||
},
|
||||
preToolUse(toolUseID, toolName, input) {
|
||||
return ctx.hook(
|
||||
"PreToolUse",
|
||||
{ tool_name: toolName, tool_input: input, tool_use_id: toolUseID },
|
||||
toolUseID,
|
||||
);
|
||||
},
|
||||
postToolUse(toolUseID, toolName, input, response) {
|
||||
return ctx.hook(
|
||||
"PostToolUse",
|
||||
@@ -960,6 +982,8 @@ export async function* toolCall(
|
||||
) {
|
||||
const parent = outcome.parent ?? null;
|
||||
yield* assistantTurn(`msg_${tool.id}`, [{ type: "tool_use", ...tool }], parent);
|
||||
// Claude Code runs the PreToolUse hooks before it asks for permission.
|
||||
await ctx.preToolUse(tool.id, tool.name, tool.input);
|
||||
if (outcome.ask) {
|
||||
await ctx.canUseTool(
|
||||
tool.name,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* schema, so a malformed v2 message fails the test.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -1111,6 +1111,118 @@ describe("ACP v2 prompts", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Hook callbacks bypass the SDK's message queue, so the hook can also come
|
||||
// before the agent has seen the streamed tool_use.
|
||||
it.each([
|
||||
["after the streamed tool_use", false],
|
||||
["before the streamed tool_use", true],
|
||||
])(
|
||||
"shows an auto-approved Edit's exact patch before the Edit runs, with the hook %s",
|
||||
async (_, hookFirst) => {
|
||||
const file = path.join(cwd, "app.ts");
|
||||
await writeFile(file, "line 1\nconst value = 1;\nline 3\n");
|
||||
const input = {
|
||||
file_path: file,
|
||||
old_string: "const value = 1;",
|
||||
new_string: "const value = 2;",
|
||||
};
|
||||
scriptTurns(async function* (options) {
|
||||
const toolUse = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "msg_edit",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [{ type: "tool_use", id: "toolu_edit", name: "Edit", input }],
|
||||
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 runs the PreToolUse hooks and waits for them; no
|
||||
// permission request comes for an auto-approved Edit.
|
||||
const preToolUse = async () => {
|
||||
for (const matcher of options.hooks?.PreToolUse ?? []) {
|
||||
for (const hook of matcher.hooks) {
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
session_id: options.sessionId!,
|
||||
cwd,
|
||||
tool_name: "Edit",
|
||||
tool_input: input,
|
||||
tool_use_id: "toolu_edit",
|
||||
} as never,
|
||||
"toolu_edit",
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (hookFirst) await preToolUse();
|
||||
yield toolUse;
|
||||
if (!hookFirst) await preToolUse();
|
||||
// The Edit runs.
|
||||
await writeFile(file, "line 1\nconst value = 2;\nline 3\n");
|
||||
yield {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_edit",
|
||||
content: "The file has been updated.",
|
||||
},
|
||||
],
|
||||
},
|
||||
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 edits = client
|
||||
.updates("tool_call_update")
|
||||
.filter((update) => update.toolCallId === "toolu_edit");
|
||||
const withPatch = edits.findIndex((update) =>
|
||||
update.content?.some((item) => item.type === "diff"),
|
||||
);
|
||||
// The patch of the file before the Edit: after it, old_string no longer
|
||||
// matches, and no patch could be built.
|
||||
expect(edits[withPatch]?.content).toEqual([
|
||||
expect.objectContaining({
|
||||
changes: [{ operation: "modify", path: file, fileType: "text" }],
|
||||
patch: {
|
||||
format: "git_patch",
|
||||
text: expect.stringContaining("-const value = 1;\n+const value = 2;\n"),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
// It arrives before the Edit's result.
|
||||
expect(withPatch).toBeLessThan(edits.findIndex((update) => update.status === "completed"));
|
||||
// The client got the tool call once, with its title, and the stream did
|
||||
// not replace the patch.
|
||||
expect(edits[0]).toMatchObject({ title: expect.any(String) });
|
||||
expect(
|
||||
edits.slice(withPatch + 1).some((update) => update.content && update.content.length === 0),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a tool call whose run was interrupted as cancelled", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield {
|
||||
|
||||
@@ -173,6 +173,18 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the PreToolUse hook of the change previews only on a v2 connection", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
// A v1 client gets the input's diff, so Claude Code waits on no hook.
|
||||
expect(capturedOptions!.hooks?.PreToolUse).toBeUndefined();
|
||||
|
||||
const v2Agent = new ClaudeAcpAgent(createMockClient(), undefined, { v2: true });
|
||||
await v2Agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.hooks?.PreToolUse).toEqual([
|
||||
{ matcher: "Edit|Write", hooks: [expect.any(Function)] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
|
||||
Reference in New Issue
Block a user