diff --git a/src/acp-agent.ts b/src/acp-agent.ts index c7b5284a..dbd7b5ea 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -179,7 +179,10 @@ import { SessionTiming } from "./session-timing.js"; import { ALLOW_BYPASS } from "./permissions/modes.js"; import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js"; import { buildClaudePermissionOptions } from "./permissions/options.js"; -import { buildClaudePermissionPresentation } from "./permissions/presentation.js"; +import { + type AcpPermissionRequest, + buildClaudePermissionPresentation, +} from "./permissions/presentation.js"; import { decodeClaudePermissionResponse } from "./permissions/response.js"; import { SettingsManager } from "./settings.js"; import { @@ -2114,7 +2117,7 @@ export interface AcpClient { * permission request so the client can dismiss its prompt (and settle our * await) instead of leaving the dialog open after the turn was cancelled. */ requestPermission( - params: RequestPermissionRequest, + params: AcpPermissionRequest, signal?: AbortSignal, ): Promise; readTextFile(params: ReadTextFileRequest): Promise; @@ -2200,7 +2203,7 @@ class ChangedMetaClient implements AcpClient { } requestPermission( - params: RequestPermissionRequest, + params: AcpPermissionRequest, signal?: AbortSignal, ): Promise { return this.inner.requestPermission(params, signal); @@ -8123,7 +8126,7 @@ export class ClaudeAcpAgent { * ignores it. A `cancelled` outcome, request rejection, and local abort all * surface the same "Tool use aborted" the callers already expect. */ private async requestPermissionFromClient( - params: RequestPermissionRequest, + params: AcpPermissionRequest, toolName: string, signal: AbortSignal, parentToolUseId: string | undefined, @@ -8328,9 +8331,15 @@ export class ClaudeAcpAgent { const noPersistentRule = matchedAskRule !== undefined || suppressAlwaysAllowRule === true; const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, noPersistentRule); const capabilities = this.toolCallCapabilities; - const previewContent = capabilities.diffPatch - ? await previewPatchContent(toolName, toolInput, session.cwd) - : undefined; + const previewContent = + capabilities.diffPatch || capabilities.v2 + ? await previewPatchContent( + toolName, + toolInput, + session.cwd, + capabilities.v2 ? "v2" : "air", + ) + : undefined; const presentation = buildClaudePermissionPresentation({ toolName, input: toolInput, diff --git a/src/diff.ts b/src/diff.ts index fb4cea8a..5e6e486b 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -108,8 +108,8 @@ type V2DiffContent = v2.Diff & { type: "diff" }; * normalizes quotes or fails. * * A Write always gets content that shows the change: the patch, the standard - * diff of a text that cannot have an exact patch, or a notice that the Write - * overwrites a file that the adapter cannot show. + * diff (in v2, the change alone) of a text that cannot have an exact patch, or + * a notice that the Write overwrites a file that the adapter cannot show. * * The preview uses the tool input as it is. Claude can still remove the * trailing whitespace of a line before it writes. The PostToolUse hook then @@ -119,6 +119,7 @@ export async function previewPatchContent( toolName: string, input: Record, cwd?: string, + form: PatchForm = "air", ): Promise { if (toolName === "Edit") { const edit = input as EditPreviewInput; @@ -142,7 +143,7 @@ export async function previewPatchContent( if (oldText !== null && (oldText.trim() !== "" || oldText.includes("\uFEFF"))) { return undefined; } - return airPatch(filePath, oldText, newString); + return exactPatch(filePath, oldText, newString, form); } if (oldText === null) return undefined; const occurrences = oldText.split(oldString).length - 1; @@ -151,7 +152,7 @@ export async function previewPatchContent( return undefined; } const newText = replacedText(oldText, oldString, newString, edit.replace_all === true); - return airPatch(filePath, oldText, newText); + return exactPatch(filePath, oldText, newText, form); } if (toolName === "Write") { @@ -177,11 +178,13 @@ export async function previewPatchContent( } if (oldText === content) return undefined; const standard: ToolCallContent[] = [ - { type: "diff", path: write.file_path, oldText, newText: content }, + form === "v2" + ? v2DiffContent(filePath, oldText === null ? "create" : "update") + : { type: "diff", path: write.file_path, oldText, newText: content }, ]; if (!isPatchableText(content)) return standard; // A dense change can exceed the diff budget. The approval then shows the standard diff. - return airPatch(filePath, oldText, content) ?? standard; + return exactPatch(filePath, oldText, content, form) ?? standard; } return undefined; @@ -276,11 +279,7 @@ export async function patchUpdateFromDiffToolResponse( const patch = filePatch(oldText, newText); if (!patch) return undefined; return { - content: [ - form === "v2" - ? v2DiffContent(response.filePath, patch.change, patch.hunks) - : airPatchContent(response.filePath, patch), - ], + content: [patchContent(response.filePath, patch, form)], // A created file keeps the location of its Write tool call. locations: oldText === null @@ -633,14 +632,22 @@ function isPatchableText(text: string): boolean { ); } -/** The AIR patch content of a change, or undefined when it has no patch. */ -function airPatch( +/** The exact patch content of a change, or undefined when it has no patch. */ +function exactPatch( filePath: string, oldText: string | null, newText: string, + form: PatchForm, ): ToolCallContent[] | undefined { const patch = filePatch(oldText, newText); - return patch && [airPatchContent(filePath, patch)]; + return patch && [patchContent(filePath, patch, form)]; +} + +/** The content that carries a patch, in the given form. */ +function patchContent(filePath: string, patch: FilePatch, form: PatchForm): ToolCallContent { + return form === "v2" + ? v2DiffContent(filePath, patch.change, patch.hunks) + : airPatchContent(filePath, patch); } /** diff --git a/src/permissions/presentation.ts b/src/permissions/presentation.ts index 5f312e94..7a8505c4 100644 --- a/src/permissions/presentation.ts +++ b/src/permissions/presentation.ts @@ -3,13 +3,26 @@ import { AIR_PERMISSION_KEY, withAirMeta } from "../air-extension.js"; import { ClientCapabilities } from "../tool-calls/client-capabilities.js"; import { AcpToolCallRenderer } from "../tool-calls/renderer.js"; +/** + * A permission request as the agent builds it. For a v2 client it also + * carries the heading and the explanation of the prompt, which ACP v2 sends as + * the request's `title` and `description`. v1 has no field for them; AIR gets + * them in `_meta`. + */ +export type AcpPermissionRequest = RequestPermissionRequest & { + /** The heading of the prompt. It is not the title of the tool call. */ + title?: string; + /** Why the permission is asked. */ + description?: string; +}; + export interface ClaudePermissionPresentationInput { toolName: string; input: Record; toolUseID: string; cwd?: string; capabilities?: ClientCapabilities; - /** The exact patch of the change to approve, for a negotiated `diffPatch` client. */ + /** The exact patch of the change to approve, for a negotiated `diffPatch` client or a v2 client. */ previewContent?: ToolCallContent[]; blockedPath?: string; title?: string; @@ -50,10 +63,13 @@ function compactText(value: unknown): string | undefined { * adapter emits the `tool_call` before the request, so the client already * holds the rest. The request adds only what it shows new: the exact preview * patch, and the blocked path when the tool call has no such location. + * + * The heading and the explanation of the prompt go to AIR in `_meta`, and to a + * v2 client as `title` and `description`. */ export function buildClaudePermissionPresentation( value: ClaudePermissionPresentationInput, -): Pick { +): Pick { const capabilities = value.capabilities ?? new ClientCapabilities(); const renderer = new AcpToolCallRenderer(capabilities); const toolUse = { id: value.toolUseID, name: value.toolName, input: value.input }; @@ -116,5 +132,6 @@ export function buildClaudePermissionPresentation( }), } : {}), + ...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}), }; } diff --git a/src/tests/acp-scenarios-v2.test.ts b/src/tests/acp-scenarios-v2.test.ts index c8c81288..d9db6fd9 100644 --- a/src/tests/acp-scenarios-v2.test.ts +++ b/src/tests/acp-scenarios-v2.test.ts @@ -62,7 +62,6 @@ const PASSES = "passes"; /** Step 4 of `docs/acp-v2.md`. */ const REPLAY = "Invalid params: The ACP v2 surface does not replay session history yet"; /** Step 5 of `docs/acp-v2.md`. */ -const PERMISSIONS = "The ACP v2 surface does not serve session/request_permission yet"; const PLANS = "The ACP v2 surface does not translate plan session updates yet"; /** @@ -72,35 +71,35 @@ const PLANS = "The ACP v2 surface does not translate plan session updates yet"; const V2_STATUS: Record = { "session-setup": PASSES, "text-and-thinking": PASSES, - "bash-foreground": PERMISSIONS, + "bash-foreground": PASSES, "bash-error": PASSES, "bash-background": PASSES, "async-task-held-until-tool-id": PASSES, "async-task-ends-before-tool-id": PASSES, "async-task-released-at-turn-end": PASSES, read: PASSES, - "write-new": PERMISSIONS, + "write-new": PASSES, "write-existing": PASSES, - "edit-with-permission": PERMISSIONS, - "edit-rejected": PERMISSIONS, - "notebook-edit": PERMISSIONS, + "edit-with-permission": PASSES, + "edit-rejected": PASSES, + "notebook-edit": PASSES, "grep-and-glob": PASSES, - "web-fetch-and-search": PERMISSIONS, - "subagent-task-legacy": PERMISSIONS, + "web-fetch-and-search": PASSES, + "subagent-task-legacy": PASSES, "subagent-agent-async": PASSES, - "subagent-native-sessions": PERMISSIONS, + "subagent-native-sessions": PASSES, "subagent-nested": PASSES, "subagent-late-child-update": PASSES, "subagent-transcript-extension": PASSES, "todo-write": PLANS, "task-create-update": PLANS, goal: PASSES, - "network-permission": PERMISSIONS, - "exit-plan-approve": PERMISSIONS, - "exit-plan-reject": PERMISSIONS, + "network-permission": PASSES, + "exit-plan-approve": PASSES, + "exit-plan-reject": PASSES, "ask-user-question": PASSES, skill: PASSES, - "mcp-tool": PERMISSIONS, + "mcp-tool": PASSES, "task-output-and-stop": PASSES, "memory-recall": PASSES, "permission-denied": PASSES, diff --git a/src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl new file mode 100644 index 00000000..051a1a81 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl @@ -0,0 +1,21 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"ls -la"},"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}} +{"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":"Bash"}},"content":[{"content":{"text":"List files","type":"text"},"type":"content"}],"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}} +{"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":[{"content":{"text":"List files","type":"text"},"type":"content"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls -la","description":"List files"},"status":"pending","title":"ls -la","toolCallId":"toolu_bash"},"type":"tool_call"},"title":"ls -la"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"```console\na.ts\nb.ts\n```","type":"text"},"type":"content"}],"rawOutput":"a.ts\nb.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"interrupted":false,"isImage":false,"stderr":"","stdout":"a.ts\nb.ts"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":{"text":"Do","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":{"text":"ne.","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}} +{"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":{"_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/edit-rejected.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl new file mode 100644 index 00000000..9ee5bb85 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl @@ -0,0 +1,18 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"locations":[{"path":"/src/app.ts"}],"rawInput":{"file_path":"/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}} +{"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":{"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"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[{"content":{"text":"```\nThe user doesn't want to proceed with this tool use. The tool use was rejected.\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use. The tool use was rejected.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_edit"}}} +{"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/edit-with-permission.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl new file mode 100644 index 00000000..c10f7d45 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl @@ -0,0 +1,19 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"locations":[{"path":"/src/app.ts"}],"rawInput":{"file_path":"/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}} +{"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":{"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"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawOutput":"The file /src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_edit"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit","toolResponse":{"filePath":"/src/app.ts","newString":"const value = 2;","oldString":"const value = 1;","originalFile":"line 1\nconst value = 1;\nline 3\n","replaceAll":false,"structuredPatch":[{"lines":[" line 1","-const value = 1;","+const value = 2;"," line 3"],"newLines":3,"newStart":1,"oldLines":3,"oldStart":1}],"userModified":false}}},"locations":[{"line":1,"path":"/src/app.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}} +{"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/exit-plan-approve.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/exit-plan-approve.jsonl new file mode 100644 index 00000000..c15ab5f8 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/exit-plan-approve.jsonl @@ -0,0 +1,17 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}} +{"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":"ExitPlanMode"}},"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}} +{"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, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"rawOutput":"User has approved your plan. You can now start coding.\n\n## Approved Plan:\n# Plan\n1. Do it","sessionUpdate":"tool_call_update","status":"completed","title":"Exited Plan Mode","toolCallId":"toolu_plan"}}} +{"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/exit-plan-reject.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/exit-plan-reject.jsonl new file mode 100644 index 00000000..d226673f --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/exit-plan-reject.jsonl @@ -0,0 +1,16 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}} +{"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":"ExitPlanMode"}},"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}} +{"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, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[{"content":{"text":"```\n```\nThe user doesn't want to proceed with this tool use.\n```\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_plan"}}} +{"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/mcp-tool.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/mcp-tool.jsonl new file mode 100644 index 00000000..8ff2b347 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/mcp-tool.jsonl @@ -0,0 +1,17 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}} +{"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":"mcp__docs__search"}},"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}} +{"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":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"},"toolName":"mcp__docs__search"}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{"q":"acp"},"status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"},"type":"tool_call"},"title":"mcp__docs__search"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"}},"content":[{"content":{"text":"Found 2 documents.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Found 2 documents.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_mcp"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search","toolResponse":[{"text":"Found 2 documents.","type":"text"}]}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}} +{"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/network-permission.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/network-permission.jsonl new file mode 100644 index 00000000..0b8a75ff --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/network-permission.jsonl @@ -0,0 +1,11 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"SandboxNetworkAccess"}},"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call_update","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"requires_action"}}} +{"kind":"request","method":"session/request_permission","payload":{"description":"Reason: Sandbox blocks network","options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"","subject":{"toolCall":{"content":[{"content":{"text":"```json\n{\n \"host\": \"example.com\"\n}\n```","type":"text"},"type":"content"}],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"status":"pending","title":"example.com","toolCallId":"toolu_net"},"type":"tool_call"},"title":"example.com"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"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/notebook-edit.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/notebook-edit.jsonl new file mode 100644 index 00000000..cf5eae25 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/notebook-edit.jsonl @@ -0,0 +1,19 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"content":[],"kind":"other","name":"NotebookEdit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"NotebookEdit","toolCallId":"toolu_nb"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"rawInput":{"notebook_path":"/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"rawInput":{"cell_id":"cell-1","notebook_path":"/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"rawInput":{"cell_id":"cell-1","new_source":"print('hi')","notebook_path":"/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}} +{"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":"NotebookEdit"}},"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}} +{"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":[],"kind":"other","name":"NotebookEdit","rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"/n.ipynb"},"status":"pending","title":"NotebookEdit","toolCallId":"toolu_nb"},"type":"tool_call"},"title":"NotebookEdit"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"content":[{"content":{"text":"Updated cell cell-1 with print('hi')","type":"text"},"type":"content"}],"rawOutput":"Updated cell cell-1 with print('hi')","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_nb"}}} +{"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/subagent-native-sessions.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl new file mode 100644 index 00000000..3e848b32 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl @@ -0,0 +1,28 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_native"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Fix the build"},"sessionUpdate":"tool_call_update","title":"Fix the build","toolCallId":"toolu_native"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Fix the build","prompt":"Make the build pass"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native"}}} +{"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":"Agent"}},"content":[{"content":{"text":"Make the build pass","type":"text"},"type":"content"}],"rawInput":{"description":"Fix the build","prompt":"Make the build pass","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"rawInput":{"pattern":"TODO"},"sessionUpdate":"tool_call_update","title":"grep \"TODO\"","toolCallId":"toolu_native_grep"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"rawInput":{"path":"/src","pattern":"TODO"},"sessionUpdate":"tool_call_update","title":"grep \"TODO\" /src","toolCallId":"toolu_native_grep"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"content":[{"content":{"text":"src/a.ts","type":"text"},"type":"content"}],"rawOutput":"src/a.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_native_grep"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"content":[],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_native_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"rawInput":{"command":"make"},"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_native_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"content":[{"content":{"text":"Build","type":"text"},"type":"content"}],"rawInput":{"command":"make","description":"Build"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native_bash"}}} +{"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":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"content":[{"content":{"text":"Build","type":"text"},"type":"content"}],"kind":"execute","name":"Bash","rawInput":{"command":"make","description":"Build"},"status":"pending","title":"make","toolCallId":"toolu_native_bash"},"type":"tool_call"},"title":"make"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"content":[{"content":{"text":"```\nmake: *** Error 2\n```","type":"text"},"type":"content"}],"rawOutput":"make: *** Error 2","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"```\nThe build still fails.\n```","type":"text"},"type":"content"}],"rawOutput":[{"text":"The build still fails.","type":"text"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native"}}} +{"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/subagent-task-legacy.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-task-legacy.jsonl new file mode 100644 index 00000000..4181e126 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-task-legacy.jsonl @@ -0,0 +1,40 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[],"kind":"think","name":"Task","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_task"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"rawInput":{"description":"Explore code"},"sessionUpdate":"tool_call_update","title":"Explore code","toolCallId":"toolu_task"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"rawInput":{"description":"Explore code","prompt":"Find the parser"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}} +{"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":"Task"}},"content":[{"content":{"text":"Find the parser","type":"text"},"type":"content"}],"rawInput":{"description":"Explore code","prompt":"Find the parser","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"locations":[{"line":1,"path":"/x.ts"}],"rawInput":{"file_path":"/x.ts"},"sessionUpdate":"tool_call_update","title":"Read /x.ts","toolCallId":"toolu_sub_read"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Task","toolResponse":{"elapsedTimeSeconds":3,"subagentType":"Explore"}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_task"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"content":[{"content":{"text":"```\n1\tparser\n```","type":"text"},"type":"content"}],"rawOutput":"1\tparser","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_read"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"content":[],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_sub_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"rawInput":{"command":"rg parser"},"sessionUpdate":"tool_call_update","title":"rg parser","toolCallId":"toolu_sub_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"content":[{"content":{"text":"Search","type":"text"},"type":"content"}],"rawInput":{"command":"rg parser","description":"Search"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_bash"}}} +{"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":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"content":[{"content":{"text":"Search","type":"text"},"type":"content"}],"kind":"execute","name":"Bash","rawInput":{"command":"rg parser","description":"Search"},"status":"pending","title":"rg parser","toolCallId":"toolu_sub_bash"},"type":"tool_call"},"title":"rg parser"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"content":[{"content":{"text":"```console\nx.ts\n```","type":"text"},"type":"content"}],"rawOutput":"x.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_sub_edit"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"locations":[{"path":"/x.ts"}],"rawInput":{"file_path":"/x.ts"},"sessionUpdate":"tool_call_update","title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"rawInput":{"file_path":"/x.ts","old_string":"a"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"rawInput":{"file_path":"/x.ts","new_string":"b","old_string":"a"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_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":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"content":[],"kind":"edit","locations":[{"path":"/x.ts"}],"name":"Edit","rawInput":{"file_path":"/x.ts","new_string":"b","old_string":"a"},"status":"pending","title":"Edit x.ts","toolCallId":"toolu_sub_edit"},"type":"tool_call"},"title":"Edit x.ts"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"rawOutput":"The file has been updated.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_edit"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit","toolResponse":{"filePath":"/x.ts","newString":"b","oldString":"a","originalFile":"a\n","replaceAll":false,"structuredPatch":[{"lines":["-a","+b"],"newLines":1,"newStart":1,"oldLines":1,"oldStart":1}],"userModified":false}}},"content":[{"changes":[{"fileType":"text","operation":"modify","path":"/x.ts"}],"patch":{"format":"git_patch","text":"diff --git /x.ts /x.ts\n--- /x.ts\n+++ /x.ts\n@@ -1 +1 @@\n-a\n+b\n"},"type":"diff"}],"locations":[{"line":1,"path":"/x.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Foun","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"d it.","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[{"content":{"text":"The parser is in x.ts.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The parser is in x.ts.\nagentId: a1 (use SendMessage to continue)","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_task"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Task","toolResponse":{"agentId":"a1","content":[{"text":"The parser is in x.ts.","type":"text"}],"prompt":"Find the parser","status":"completed","totalDurationMs":10,"totalTokens":5,"totalToolUseCount":1}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}} +{"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/web-fetch-and-search.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/web-fetch-and-search.jsonl new file mode 100644 index 00000000..d72b9e8c --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/web-fetch-and-search.jsonl @@ -0,0 +1,21 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"content":[],"kind":"fetch","name":"WebFetch","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Fetch","toolCallId":"toolu_fetch"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"rawInput":{"url":"https://example.com"},"sessionUpdate":"tool_call_update","title":"Fetch https://example.com","toolCallId":"toolu_fetch"}}} +{"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":"WebFetch"}},"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_fetch"}}} +{"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":"allow_always","name":"Yes, and don't ask again for example.com","optionId":"allow-with-updates"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"","subject":{"toolCall":{"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"kind":"fetch","name":"WebFetch","rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"status":"pending","title":"Fetch https://example.com","toolCallId":"toolu_fetch"},"type":"tool_call"},"title":"Fetch https://example.com"}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"content":[{"content":{"text":"The page is an example.","type":"text"},"type":"content"}],"rawOutput":"The page is an example.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_fetch"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"WebSearch"}},"content":[],"kind":"fetch","name":"WebSearch","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Web search","toolCallId":"toolu_search"}}} +{"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":"WebSearch"}},"rawInput":{"query":"acp protocol"},"sessionUpdate":"tool_call_update","title":"Search \"acp protocol\"","toolCallId":"toolu_search"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"WebSearch"}},"content":[{"content":{"text":"ACP (https://agentclientprotocol.com)\nA summary.","type":"text"},"type":"content"}],"rawOutput":"Web search results for query: \"acp protocol\"\n\nLinks: [...]","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_search"}}} +{"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 new file mode 100644 index 00000000..f6a1553c --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/write-new.jsonl @@ -0,0 +1,18 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}} +{"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":{"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"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawOutput":"File created successfully at: /new.ts","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":"/new.ts","originalFile":null,"structuredPatch":[],"type":"create"}}},"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/harness.ts b/src/tests/acp-scenarios/harness.ts index 02b173b0..a9550818 100644 --- a/src/tests/acp-scenarios/harness.ts +++ b/src/tests/acp-scenarios/harness.ts @@ -312,12 +312,7 @@ export async function runScenario( }, async requestPermission(request: RequestPermissionRequest) { record("requestPermission", request); - const wanted = scenario.permission ?? "allow_once"; - const option = - request.options.find((o) => o.kind === wanted) ?? - request.options.find((o) => o.kind.startsWith(wanted.split("_")[0])) ?? - request.options[0]; - return { outcome: { outcome: "selected", optionId: option.optionId } }; + return permissionAnswer(scenario, request.options); }, async createElicitation(request: CreateElicitationRequest) { record("createElicitation", request); @@ -401,6 +396,22 @@ function elicitationAnswer(request: unknown): { return { action: "accept", content }; } +/** + * The answer of a client to a permission request: the option of the kind that + * the scenario selects, or of its allow or reject group, or the first option. + */ +function permissionAnswer( + scenario: Scenario, + options: readonly { optionId: string; kind: string }[], +) { + const wanted = scenario.permission ?? "allow_once"; + const option = + options.find((o) => o.kind === wanted) ?? + options.find((o) => o.kind.startsWith(wanted.split("_")[0])) ?? + options[0]; + return { outcome: { outcome: "selected" as const, optionId: option.optionId } }; +} + /** One JSON-RPC message that the agent sent to a v2 client. */ export interface WireRecorded { /** A request or notification to the client, or the answer to one of its requests. */ @@ -491,6 +502,9 @@ export async function runScenarioV2( } if (update.sessionUpdate === "notice" && update.title) notices.push(update.title); }) + .onRequest(v2.methods.client.session.requestPermission, ({ params }) => + permissionAnswer(scenario, params.options), + ) .onRequest(v2.methods.client.elicitation.create, ({ params }) => elicitationAnswer(params)) .onNotification(v2.methods.client.elicitation.complete, () => {}) .onNotification( diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index fcd6d519..983a03a5 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -17,6 +17,7 @@ import type { AuthStatusUpdateNotification } from "../auth-status.js"; import { v2DiffContent } from "../diff.js"; import { acpProtocolRouter } from "../serve.js"; import { clientSupportsNotices } from "../session-notices.js"; +import { v1PermissionResponse, v2PermissionRequest } from "../v2/permission.js"; import { v1PromptRequest } from "../v2/prompt.js"; import { v2SessionUpdate } from "../v2/session-update.js"; import { v1SetSessionConfigOptionRequest, v2ConfigOptions } from "../v2/session.js"; @@ -147,13 +148,19 @@ function connectRouter(): v2.Stream { /** * A v2 client app that collects every `_auth/status_update`, session update, - * and elicitation it receives, and accepts every elicitation. + * elicitation, and permission request it receives, accepts every elicitation, + * and answers permission requests with `answerPermission`. */ -function v2Client() { +function v2Client({ + answerPermission = () => ({ outcome: { outcome: "cancelled" } }), +}: { + answerPermission?: (request: v2.RequestPermissionRequest) => v2.RequestPermissionResponse; +} = {}) { const authUpdates: AuthStatusUpdateNotification[] = []; const sessionUpdates: v2.UpdateSessionNotification[] = []; const elicitations: v2.CreateElicitationRequest[] = []; const completedElicitations: v2.CompleteElicitationNotification[] = []; + const permissionRequests: v2.RequestPermissionRequest[] = []; let notifyAuthUpdate = () => {}; const app = v2 .client({ name: V2_CLIENT_INFO.name }) @@ -174,6 +181,10 @@ function v2Client() { }) .onNotification(v2.methods.client.elicitation.complete, ({ params }) => { completedElicitations.push(params); + }) + .onRequest(v2.methods.client.session.requestPermission, ({ params }) => { + permissionRequests.push(params); + return answerPermission(params); }); /** Resolves once the client has received `count` auth status updates. */ const authUpdate = (count: number) => @@ -193,7 +204,15 @@ function v2Client() { (update): update is Extract => update.sessionUpdate === kind, ); - return { app, authUpdate, sessionUpdates, updates, elicitations, completedElicitations }; + return { + app, + authUpdate, + sessionUpdates, + updates, + elicitations, + completedElicitations, + permissionRequests, + }; } async function initializeV2(agent: v2.ClientContext, capabilities: v2.ClientCapabilities = {}) { @@ -621,6 +640,96 @@ describe("ACP v2 prompts", () => { ]); }); + it("asks for permission with a title of its own, and reads the answer back", async () => { + const decisions: unknown[] = []; + scriptTurns(async function* (options) { + // Claude Code asks about each Bash tool use, then returns its result. + for (const toolUseID of ["toolu_allow", "toolu_reject", "toolu_custom"]) { + yield { + type: "assistant", + message: { + id: `msg_${toolUseID}`, + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "tool_use", id: toolUseID, name: "Bash", input: { command: "ls" } }], + 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, + }; + const decision = await options.canUseTool!("Bash", { command: "ls" }, { + signal: new AbortController().signal, + toolUseID, + suggestions: [], + } as unknown as Parameters>[2]).catch( + (error: Error) => error.message, + ); + decisions.push(decision); + yield { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseID, + content: "done", + is_error: (decision as { behavior?: string }).behavior !== "allow", + }, + ], + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: options.sessionId, + }; + } + yield result(options); + }); + const answers: v2.RequestPermissionResponse[] = [ + { outcome: { outcome: "selected", optionId: "allow-once" } }, + { outcome: { outcome: "selected", optionId: "reject" } }, + // The draft says an outcome that the agent does not know is no approval. + { outcome: { outcome: "_later" } }, + ]; + const client = v2Client({ answerPermission: () => answers.shift()! }); + 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); + }); + + expect(client.permissionRequests[0]).toMatchObject({ + title: "ls", + subject: { type: "tool_call", toolCall: { toolCallId: "toolu_allow", title: "ls" } }, + options: [ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "reject", kind: "reject_once" }, + ], + }); + expect(decisions).toEqual([ + expect.objectContaining({ behavior: "allow" }), + expect.objectContaining({ behavior: "deny" }), + "Tool use aborted", + ]); + expect(turnTrace(client.sessionUpdates)).toEqual([ + "user_message", + "running", + "requires_action", + "running", + "requires_action", + "running", + "requires_action", + "running", + "idle end_turn", + ]); + }); + it("answers a queued prompt once Claude Code takes it in, after the turn before it", async () => { const firstAnswer = Promise.withResolvers(); const echoed = scriptTurns( @@ -986,6 +1095,32 @@ describe("ACP v2 session translation", () => { ).toEqual({ sessionUpdate: "tool_call_update", toolCallId: "t", content: [diff] }); }); + it("fails on a permission request without the title that v2 requires", () => { + const request = { + sessionId: "s", + toolCall: { toolCallId: "t", title: "ls" }, + options: [{ optionId: "allow-once", name: "Yes", kind: "allow_once" as const }], + }; + expect(() => v2PermissionRequest(request)).toThrow( + "An ACP v2 permission request needs a title", + ); + expect(v2PermissionRequest({ ...request, title: "Run ls?" })).toEqual({ + sessionId: "s", + title: "Run ls?", + subject: { type: "tool_call", toolCall: { toolCallId: "t", title: "ls" } }, + options: request.options, + }); + }); + + it("reads an unknown permission outcome as cancelled, never as approval", () => { + expect(v1PermissionResponse({ outcome: { outcome: "_later" } })).toEqual({ + outcome: { outcome: "cancelled" }, + }); + expect(v1PermissionResponse({ outcome: { outcome: "selected", optionId: "o" } })).toEqual({ + outcome: { outcome: "selected", optionId: "o" }, + }); + }); + it("fails on tool call content that v2 cannot take", () => { const update = (content: v1.ToolCallContent) => v2SessionUpdate({ sessionUpdate: "tool_call_update", toolCallId: "t", content: [content] }); diff --git a/src/tests/diff-patch.test.ts b/src/tests/diff-patch.test.ts index 9ebe707f..af01e740 100644 --- a/src/tests/diff-patch.test.ts +++ b/src/tests/diff-patch.test.ts @@ -556,6 +556,29 @@ describe("ACP v2 diffs", () => { }); }); + it("previews a change in v2 form", async () => { + const filePath = await temporaryFile("a\n"); + expect( + await previewPatchContent("Write", { file_path: filePath, content: "b\n" }, undefined, "v2"), + ).toEqual([ + v2Diff( + filePath, + "modify", + `diff --git ${filePath} ${filePath}\n--- ${filePath}\n+++ ${filePath}\n@@ -1 +1 @@\n-a\n+b\n`, + ), + ]); + // A text that cannot have an exact patch: the change alone, where v1 gets + // the standard diff. + expect( + await previewPatchContent( + "Write", + { file_path: filePath, content: "b\r\n" }, + undefined, + "v2", + ), + ).toEqual([v2Diff(filePath, "modify")]); + }); + it("sends no diff where the operation is unknown or nothing changed", async () => { const filePath = await temporaryFile("x\n"); // An Edit with an empty old_string created the file or filled an empty one. diff --git a/src/tests/permission-presentation.test.ts b/src/tests/permission-presentation.test.ts index 6ae42803..c094df24 100644 --- a/src/tests/permission-presentation.test.ts +++ b/src/tests/permission-presentation.test.ts @@ -1,6 +1,7 @@ import { ClientCapabilities } from "../tool-calls/client-capabilities.js"; import { describe, expect, it } from "vitest"; import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"; +import { v2DiffContent } from "../diff.js"; import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js"; import { buildClaudePermissionPresentation } from "../permissions/presentation.js"; @@ -518,3 +519,39 @@ describe("Claude permission presentation for a client that is not AIR", () => { }); }); }); + +describe("Claude permission presentation for a v2 client", () => { + const v2 = new ClientCapabilities(false, false, false, undefined, true); + + it("gives the prompt a title and description of its own, apart from the tool call", () => { + const presentation = buildClaudePermissionPresentation({ + toolName: "ExitPlanMode", + input: { plan: "Do it." }, + toolUseID: "tool-4", + decisionReason: "Plan mode asks before coding.", + capabilities: v2, + }); + expect(presentation).toMatchObject({ + title: "Ready to code?", + description: "Reason: Plan mode asks before coding.", + toolCall: { title: "Approve Plan" }, + }); + expect(presentation).not.toHaveProperty("_meta"); + }); + + it("shows the exact preview patch, which the tool call does not show", () => { + const preview = [ + v2DiffContent("/work/a.ts", "update", [ + { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ["-a", "+b"] }, + ]), + ]; + const presentation = buildClaudePermissionPresentation({ + toolName: "Edit", + input: { file_path: "/work/a.ts", old_string: "a", new_string: "b" }, + toolUseID: "tool-5", + capabilities: v2, + previewContent: preview, + }); + expect(presentation.toolCall.content).toEqual(preview); + }); +}); diff --git a/src/tool-calls/renderer.ts b/src/tool-calls/renderer.ts index f722a8e9..73e9d350 100644 --- a/src/tool-calls/renderer.ts +++ b/src/tool-calls/renderer.ts @@ -248,8 +248,9 @@ export class AcpToolCallRenderer { ): RequestPermissionRequest["toolCall"] { const facts = this.facts(toolUse, options.cwd); if (!this.capabilities.air.client) { - // The upstream shape: the whole tool call again. - const content = this.toolUseContent(toolUse.id, facts); + // The upstream shape: the whole tool call again. A v2 client also gets + // the exact preview patch, which the tool call does not show. + const content = options.previewContent ?? this.toolUseContent(toolUse.id, facts); const locations = [...(facts.locations ?? []), ...(options.extraLocations ?? [])]; return { toolCallId: toolUse.id, diff --git a/src/v2/agent.ts b/src/v2/agent.ts index 4e0e428a..f61af0fb 100644 --- a/src/v2/agent.ts +++ b/src/v2/agent.ts @@ -11,11 +11,14 @@ import type { CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, + RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import * as v2 from "@agentclientprotocol/sdk/experimental/v2"; import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js"; import type { AcpSessionNotification } from "../acp-subagents.js"; +import type { AcpPermissionRequest } from "../permissions/presentation.js"; import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js"; +import { v1PermissionResponse, v2PermissionRequest } from "./permission.js"; import { v2Prompt } from "./prompt.js"; import { v1NewSessionRequest, @@ -78,8 +81,6 @@ export function v2AgentApp( /** * The {@link AcpClient} of an ACP v2 connection: it sends the v1 messages of * the agent as v2 messages. - * - * Permission requests still reject until the v2 surface translates them. */ class V2ClientConnection implements AcpClient { constructor( @@ -119,10 +120,16 @@ class V2ClientConnection implements AcpClient { } } - requestPermission(): Promise { - return Promise.reject( - new Error("The ACP v2 surface does not serve session/request_permission yet"), + async requestPermission( + params: AcpPermissionRequest, + signal?: AbortSignal, + ): Promise { + const response = await this.ctx.request( + v2.methods.client.session.requestPermission, + v2PermissionRequest(params), + { cancellationSignal: signal }, ); + return v1PermissionResponse(response); } // Elicitation is the same in v1 and v2. diff --git a/src/v2/permission.ts b/src/v2/permission.ts new file mode 100644 index 00000000..89935bc1 --- /dev/null +++ b/src/v2/permission.ts @@ -0,0 +1,50 @@ +/** + * The v2 form of the permission requests that `ClaudeAcpAgent` sends, and the + * v1 form of their answers. + */ +import type { RequestPermissionResponse } from "@agentclientprotocol/sdk"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2"; +import type { AcpPermissionRequest } from "../permissions/presentation.js"; +import { v2ToolCallPatch } from "./tool-call.js"; + +/** + * The v2 form of a permission request of the agent. + * + * v1 asks about a tool call, whose title the prompt shows. v2 gives the prompt + * its own `title` and `description`, which the agent sets for a v2 client + * (`AcpPermissionRequest`), and asks about a `subject`: here always the tool + * call, as the same patch that a `tool_call_update` carries. + */ +export function v2PermissionRequest(request: AcpPermissionRequest): v2.RequestPermissionRequest { + if (request.title === undefined) { + throw new Error("An ACP v2 permission request needs a title"); + } + return { + sessionId: request.sessionId, + title: request.title, + ...(request.description ? { description: request.description } : {}), + subject: { type: "tool_call", toolCall: v2ToolCallPatch(request.toolCall) }, + options: request.options, + ...(request._meta != null ? { _meta: request._meta } : {}), + }; +} + +/** + * The v1 form of the answer to a permission request. + * + * v2 adds outcomes that v1 does not know, which the draft says are no + * approval. They become `cancelled`, which the agent reads as an aborted tool + * use, as it does a cancelled prompt. + */ +export function v1PermissionResponse( + response: v2.RequestPermissionResponse, +): RequestPermissionResponse { + const outcome = response.outcome; + return { + outcome: + outcome.outcome === "selected" + ? (outcome as Extract) + : { outcome: "cancelled" }, + ...(response._meta != null ? { _meta: response._meta } : {}), + }; +} diff --git a/src/v2/tool-call.ts b/src/v2/tool-call.ts index 2ba297d9..59623266 100644 --- a/src/v2/tool-call.ts +++ b/src/v2/tool-call.ts @@ -1,6 +1,6 @@ /** * The v2 form of the tool call reports that `ClaudeAcpAgent` sends as v1 - * `tool_call` and `tool_call_update` updates. + * `tool_call` and `tool_call_update` updates, and in permission requests. */ import type { ToolCall, ToolCallContent, ToolCallUpdate } from "@agentclientprotocol/sdk"; import type * as v2 from "@agentclientprotocol/sdk/experimental/v2"; @@ -27,15 +27,22 @@ type V2ToolCallUpdate = v2.ToolCallUpdate & { sessionUpdate: "tool_call_update" * `docs/acp-v2.md`). */ export function v2ToolCallUpdate(report: V1ToolCallReport): V2ToolCallUpdate { + return { ...v2ToolCallPatch(report), sessionUpdate: "tool_call_update" }; +} + +/** + * The v2 form of the fields of a v1 tool call report, which a session update + * and the subject of a permission request carry. See {@link v2ToolCallUpdate}. + */ +export function v2ToolCallPatch(toolCall: ToolCall | ToolCallUpdate): v2.ToolCallUpdate { const patch = Object.fromEntries( - Object.entries(report).filter( + Object.entries(toolCall).filter( ([key, value]) => value !== null && key !== "sessionUpdate" && key !== "content", ), - ) as Omit; + ) as Omit; return { ...patch, - sessionUpdate: "tool_call_update", - ...(report.content != null ? { content: report.content.map(v2ToolCallContent) } : {}), + ...(toolCall.content != null ? { content: toolCall.content.map(v2ToolCallContent) } : {}), }; }