diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 4714df2e..f0874018 100644 --- a/src/acp-agent.ts +++ b/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 { + const session = this.sessions[sessionId]; + if (!session) return; + const toolInput = (input.tool_input ?? {}) as Record; + 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 || []), { diff --git a/src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl index 9c23b9d7..6f721533 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl @@ -9,6 +9,7 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"/src/app.ts","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"/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":"","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git /src/app.ts /src/app.ts\n--- /src/app.ts\n+++ /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":"","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":"","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git /src/app.ts /src/app.ts\n--- /src/app.ts\n+++ /src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"/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":"","update":{"sessionUpdate":"state_update","state":"running"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl index 8d0ea536..58381e89 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl @@ -9,6 +9,7 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"/src/app.ts","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"/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":"","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git /src/app.ts /src/app.ts\n--- /src/app.ts\n+++ /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":"","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":"","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git /src/app.ts /src/app.ts\n--- /src/app.ts\n+++ /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":"/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"/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":"","update":{"sessionUpdate":"state_update","state":"running"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/write-existing.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/write-existing.jsonl index e2cc45a3..c8c38d2d 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/write-existing.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/write-existing.jsonl @@ -8,7 +8,8 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"locations":[{"path":"/old.ts"}],"rawInput":{"file_path":"/old.ts"},"sessionUpdate":"tool_call_update","title":"Write old.ts","toolCallId":"toolu_write"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawInput":{"content":"export const x = 1;\n","file_path":"/old.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"/old.ts"}],"patch":{"format":"git_patch","text":"diff --git /old.ts /old.ts\n--- /old.ts\n+++ /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":"","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawOutput":"The file /old.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write","toolResponse":{"content":"export const x = 1;\n","filePath":"/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":"/old.ts"}],"patch":{"format":"git_patch","text":"diff --git /old.ts /old.ts\n--- /old.ts\n+++ /old.ts\n@@ -1 +1 @@\n-export const x = 0;\n+export const x = 1;\n"},"type":"diff"}],"locations":[{"line":1,"path":"/old.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write","toolResponse":{"content":"export const x = 1;\n","filePath":"/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":"/old.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","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":"","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}}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/write-new.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/write-new.jsonl index 5bdf9b05..5ab9e734 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/write-new.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/write-new.jsonl @@ -8,6 +8,7 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"locations":[{"path":"/new.ts"}],"rawInput":{"file_path":"/new.ts"},"sessionUpdate":"tool_call_update","title":"Write new.ts","toolCallId":"toolu_write"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawInput":{"content":"export const x = 1;\n","file_path":"/new.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"/new.ts"}],"patch":{"format":"git_patch","text":"diff --git /new.ts /new.ts\nnew file mode 100644\n--- /dev/null\n+++ /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":"","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":"","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"/new.ts"}],"patch":{"format":"git_patch","text":"diff --git /new.ts /new.ts\nnew file mode 100644\n--- /dev/null\n+++ /new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"/new.ts"}],"name":"Write","rawInput":{"content":"export const x = 1;\n","file_path":"/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":"","update":{"sessionUpdate":"state_update","state":"running"}}} diff --git a/src/tests/acp-scenarios/harness.ts b/src/tests/acp-scenarios/harness.ts index 3fc6ae90..3bd3417d 100644 --- a/src/tests/acp-scenarios/harness.ts +++ b/src/tests/acp-scenarios/harness.ts @@ -98,7 +98,10 @@ type QueryOptions = { input: Record, extra: Record, ) => Promise; - hooks?: Record Promise)[] }[]>; + hooks?: Record< + string, + { matcher?: string; hooks: ((input: any, id: string, opts: any) => Promise)[] }[] + >; }; /** What a scenario script reads and calls. */ @@ -116,8 +119,13 @@ export interface ScriptContext { toolUseID: string, extra?: Record, ): Promise; - /** 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, toolUseID?: string): Promise; + /** Calls the PreToolUse hooks of a tool use, which run before it runs. */ + preToolUse(toolUseID: string, toolName: string, input: unknown): Promise; /** Calls the PostToolUse hooks of a tool use. */ postToolUse( toolUseID: string, @@ -194,6 +202,13 @@ export function mockedQuery(args: { prompt: AsyncIterable; 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; 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, diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index a801a032..9e39227c 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -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 { diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index 435d5b6a..ac905732 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -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(),