From 3d9bae941cbdb6a31b6f8726dfd87113d49fc3bc Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 2 Oct 2026 10:03:41 -0700 Subject: [PATCH] refactor: report commands as display terminals on the experimental ACP v2 surface (#1240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third part of step 4 of opt-in ACP v2 support (after #1231, #1232, #1237): a v2 client now sees each command the agent runs in an Agent-owned display terminal, instead of its output as a code block. Nothing changes on ACP v1. ## Why a mapping The agent already reports commands through the terminal extension of ACP v1 clients, as codex-acp does: - `_meta.terminal_info` names a terminal, which the tool call's content references; - `terminal_output` (a snapshot) or `terminal_output_delta` (a chunk) carries the output; - `terminal_exit` carries the exit. v2 standardizes exactly this as display terminals: the same `{ type: "terminal", terminalId }` content, plus `terminal_update` and `terminal_output_chunk` for the terminal's state. So the v2 surface maps the extension rather than adding new agent types. Unlike the agent's private `_meta.claudeCode` keys, these keys are a cross-agent protocol extension, so this maps one protocol form to another. ## Change - `ToolCallClientCapabilities.from` reports a v2 client as taking the extension. - `V2Terminals` (`src/v2/terminal.ts`), one per connection, turns the extension's keys into v2 updates, sent before the tool call report without those keys: - `terminal_info` → `terminal_update` naming the terminal; - `terminal_output` → `terminal_update` with an `output` snapshot, base64-encoded; - `terminal_output_delta` → `terminal_output_chunk`; - `terminal_exit` → `terminal_update` with `exitStatus`. - **The command:** the extension names none, so the terminal gets the command from the tool call's `rawInput` once a report carries it. The first report of a streamed tool use has no input yet. `V2Terminals` remembers each running command's terminal for that, and forgets it at exit or `session/close`. - **A report that carried only the output is dropped.** Its remaining `_meta` (the `parentToolUseId` stamp, for a subagent's command) would replace the tool call's stored `_meta` in v2. - Terminal content passes through the tool call translation. A v2 client has no client terminals, so these are the agent's only terminals. The 8 v2 scenarios that run a command change their golden files: `terminal_update`s in place of the code block, the terminal referenced in the permission request, and the exit before the tool call's status. ## Not changed here The agent reports a backgrounded command as exiting with 0 when it launches, and a failure whose exit code it doesn't know as 1. v1 terminal clients get the same values, since the extension requires a number. v2 can leave the exit status out, or say it's unknown, but that needs the agent's command fact to carry it, and it affects v1 too, so it's left for its own PR. ## Verification - Unit tests cover output chunks and snapshots, signals, the command arriving once, the dropped output report, a report without terminal keys passing untouched, and forgetting a closed session. - Each rule fails a test when removed. - Build, lint and all 2388 tests pass. --- .../__snapshots__/v2/bash-background.jsonl | 10 +- .../__snapshots__/v2/bash-error.jsonl | 10 +- .../__snapshots__/v2/bash-foreground.jsonl | 12 +- .../__snapshots__/v2/permission-denied.jsonl | 8 +- .../v2/session-load-replay.jsonl | 7 +- .../v2/subagent-native-sessions.jsonl | 12 +- .../v2/subagent-task-legacy.jsonl | 12 +- .../__snapshots__/v2/tool-progress.jsonl | 8 +- src/tests/acp-v2.test.ts | 121 +++++++++++++++- src/tool-calls/client-capabilities.ts | 6 +- src/v2/agent.ts | 49 +++++-- src/v2/terminal.ts | 131 ++++++++++++++++++ src/v2/tool-call.ts | 11 +- 13 files changed, 350 insertions(+), 47 deletions(-) create mode 100644 src/v2/terminal.ts diff --git a/src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl index 60383ced..ca15050c 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl @@ -5,11 +5,15 @@ {"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_bg"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"command":"sleep 100","sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100"},"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100","description":"Wait"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}} {"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":"Wait","type":"text"},"type":"content"}],"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"```console\nCommand running in background with ID: bash_1. Output is being written to: /tmp/tasks/bash_1.output. You will be notified when it completes.\n```","type":"text"},"type":"content"}],"rawOutput":"Command running in background with ID: bash_1. Output is being written to: /tmp/tasks/bash_1.output. You will be notified when it completes.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"output":{"data":"Q29tbWFuZCBydW5uaW5nIGluIGJhY2tncm91bmQgd2l0aCBJRDogYmFzaF8xLiBPdXRwdXQgaXMgYmVpbmcgd3JpdHRlbiB0bzogL3RtcC90YXNrcy9iYXNoXzEub3V0cHV0LiBZb3Ugd2lsbCBiZSBub3RpZmllZCB3aGVuIGl0IGNvbXBsZXRlcy4="},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}} {"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/bash-error.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/bash-error.jsonl index bc3cc429..70f6035d 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/bash-error.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/bash-error.jsonl @@ -5,10 +5,14 @@ {"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":{"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"command":"false","sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"false"},"sessionUpdate":"tool_call_update","title":"false","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":"Fail","type":"text"},"type":"content"}],"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"```\nExit code 1\nboom\n```","type":"text"},"type":"content"}],"rawOutput":"Exit code 1\nboom","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"output":{"data":"RXhpdCBjb2RlIDEKYm9vbQ=="},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}} {"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/bash-foreground.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl index 8907a881..a2b2ebb7 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl @@ -5,14 +5,18 @@ {"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":{"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"command":"ls -la","sessionUpdate":"terminal_update","terminalId":"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":{"_meta":{"claudeCode":{"toolName":"Bash"}},"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":"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":[{"terminalId":"toolu_bash","type":"terminal"}],"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":{"output":{"data":"YS50cwpiLnRz"},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"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"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl index 416c51f4..eafc59f4 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl @@ -5,10 +5,14 @@ {"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_denied"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_denied"}}} {"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":{"command":"rm -rf /","sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"rm -rf /"},"sessionUpdate":"tool_call_update","title":"rm -rf /","toolCallId":"toolu_denied"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"decisionReason":"Denied by rule Bash(rm:*)","decisionReasonType":"rule","message":"Permission to use Bash has been denied."}}},"content":[{"content":{"text":"Permission denied: Denied by rule Bash(rm:*)","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_denied"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"```\nPermission to use Bash has been denied.\n```","type":"text"},"type":"content"}],"rawOutput":"Permission to use Bash has been denied.","sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"output":{"data":"UGVybWlzc2lvbiB0byB1c2UgQmFzaCBoYXMgYmVlbiBkZW5pZWQu"},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}} {"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/session-load-replay.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/session-load-replay.jsonl index 4fbfd0db..9ef1af49 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/session-load-replay.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/session-load-replay.jsonl @@ -5,10 +5,13 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[],"messageId":"msg_r1","sessionUpdate":"agent_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"List","type":"text"},"type":"content"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls","description":"List"},"sessionUpdate":"tool_call_update","status":"pending","title":"ls","toolCallId":"toolu_r_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"command":"ls","sessionUpdate":"terminal_update","terminalId":"toolu_r_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_r_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls","description":"List"},"sessionUpdate":"tool_call_update","status":"pending","title":"ls","toolCallId":"toolu_r_bash"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[{"path":"/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"/src/app.ts","new_string":"b","old_string":"a"},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_r_edit"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[{"content":{"text":"Look around","type":"text"},"type":"content"}],"kind":"think","name":"Task","rawInput":{"description":"Explore","prompt":"Look around"},"sessionUpdate":"tool_call_update","status":"pending","title":"Explore","toolCallId":"toolu_r_task"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"```console\napp.ts\n```","type":"text"},"type":"content"}],"rawOutput":"app.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"output":{"data":"YXBwLnRz"},"sessionUpdate":"terminal_update","terminalId":"toolu_r_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_r_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_r_bash","type":"terminal"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}} {"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_r_edit"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[{"content":{"text":"Report.","type":"text"},"type":"content"}],"rawOutput":"Report.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_task"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[],"messageId":"msg_r2","sessionUpdate":"agent_message"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl index bed487d3..e1cf40fc 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl @@ -16,13 +16,17 @@ {"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":{"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"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":{"command":"make","sessionUpdate":"terminal_update","terminalId":"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":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"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":"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":[{"terminalId":"toolu_native_bash","type":"terminal"}],"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":{"output":{"data":"bWFrZTogKioqIEVycm9yIDI="},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"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 index b80b86e6..dbcd72ab 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/subagent-task-legacy.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-task-legacy.jsonl @@ -16,13 +16,17 @@ {"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":{"sessionUpdate":"terminal_update","terminalId":"toolu_sub_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"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":{"command":"rg parser","sessionUpdate":"terminal_update","terminalId":"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":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"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":"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":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"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":{"output":{"data":"eC50cw=="},"sessionUpdate":"terminal_update","terminalId":"toolu_sub_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_sub_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"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"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/tool-progress.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/tool-progress.jsonl index 5420a404..7f8b94d9 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/tool-progress.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/tool-progress.jsonl @@ -5,12 +5,16 @@ {"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_slow"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_slow"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_slow","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_slow"}}} {"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":{"command":"make","sessionUpdate":"terminal_update","terminalId":"toolu_slow"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"make"},"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_slow"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"elapsedTimeSeconds":1}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_slow"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"elapsedTimeSeconds":2}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"elapsedTimeSeconds":3}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"```console\nbuilt\n```","type":"text"},"type":"content"}],"rawOutput":"built","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_slow"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"output":{"data":"YnVpbHQ="},"sessionUpdate":"terminal_update","terminalId":"toolu_slow"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_slow"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_slow"}}} {"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-v2.test.ts b/src/tests/acp-v2.test.ts index 4f63af50..5da8fac8 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -23,6 +23,7 @@ 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 { V2Terminals } from "../v2/terminal.js"; import { v2SessionUpdate } from "../v2/session-update.js"; import { v1SetSessionConfigOptionRequest, v2ConfigOptions } from "../v2/session.js"; import packageJson from "../../package.json" with { type: "json" }; @@ -1226,6 +1227,118 @@ describe("ACP v2 session translation", () => { ).toEqual({ sessionUpdate: "tool_call_update", toolCallId: "t", content: [diff] }); }); + it("turns the terminal extension of a command into a display terminal", () => { + const terminals = new V2Terminals(); + const base64 = (text: string) => Buffer.from(text).toString("base64"); + const toolCallId = "toolu_bash"; + // The first report of a streamed tool use names the terminal, without a command yet. + expect( + terminals.split("s", { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + rawInput: {}, + content: [{ type: "terminal", terminalId: "term" }], + _meta: { claudeCode: { toolName: "Bash" }, terminal_info: { terminal_id: "term" } }, + }), + ).toEqual({ + terminal: [{ sessionUpdate: "terminal_update", terminalId: "term" }], + report: { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + rawInput: {}, + content: [{ type: "terminal", terminalId: "term" }], + _meta: { claudeCode: { toolName: "Bash" } }, + }, + }); + // The command, once the input carries it, and only once. + const refinement = { + sessionUpdate: "tool_call_update" as const, + toolCallId, + rawInput: { command: "make" }, + }; + expect(terminals.split("s", refinement).terminal).toEqual([ + { sessionUpdate: "terminal_update", terminalId: "term", command: "make" }, + ]); + expect(terminals.split("s", refinement).terminal).toEqual([]); + // A chunk of output, then the output report that carries nothing else. + expect( + terminals.split("s", { + sessionUpdate: "tool_call_update", + toolCallId, + _meta: { + claudeCode: { parentToolUseId: "toolu_agent" }, + terminal_output_delta: { terminal_id: "term", data: "bu" }, + }, + }), + ).toEqual({ + terminal: [ + { sessionUpdate: "terminal_output_chunk", terminalId: "term", data: base64("bu") }, + ], + report: undefined, + }); + // The exit, then the status. + expect( + terminals.split("s", { + sessionUpdate: "tool_call_update", + toolCallId, + status: "failed", + _meta: { + claudeCode: { toolName: "Bash" }, + terminal_output: { terminal_id: "term", data: "build" }, + terminal_exit: { terminal_id: "term", exit_code: 2, signal: "SIGTERM" }, + }, + }), + ).toEqual({ + terminal: [ + { + sessionUpdate: "terminal_update", + terminalId: "term", + output: { data: base64("build") }, + exitStatus: { exitCode: 2, signal: "SIGTERM" }, + }, + ], + report: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "failed", + _meta: { claudeCode: { toolName: "Bash" } }, + }, + }); + // The command exited: a later report updates no terminal, and a report + // without the extension's keys passes as it is. + const hook = { + sessionUpdate: "tool_call_update" as const, + toolCallId, + rawInput: { command: "make all" }, + _meta: { claudeCode: { toolResponse: { stdout: "build" } } }, + }; + expect(terminals.split("s", hook)).toEqual({ terminal: [], report: hook }); + }); + + it("forgets the terminals of a closed session", () => { + const terminals = new V2Terminals(); + const named = (sessionId: string) => + terminals.split(sessionId, { + sessionUpdate: "tool_call", + toolCallId: "toolu_bash", + title: "Terminal", + _meta: { terminal_info: { terminal_id: "term" } }, + }); + const commanded = (sessionId: string) => + terminals.split(sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: "toolu_bash", + rawInput: { command: "ls" }, + }).terminal; + named("closed"); + named("open"); + terminals.forget("closed"); + expect(commanded("closed")).toEqual([]); + expect(commanded("open")).toHaveLength(1); + }); + it("fails on a permission request without the title that v2 requires", () => { const request = { sessionId: "s", @@ -1252,16 +1365,16 @@ describe("ACP v2 session translation", () => { }); }); - it("fails on tool call content that v2 cannot take", () => { + it("fails on a v1 diff, and passes a display terminal as it is", () => { const update = (content: v1.ToolCallContent) => v2SessionUpdate({ sessionUpdate: "tool_call_update", toolCallId: "t", content: [content] }); // A v1 diff can hold a snippet, without the operation or line numbers. expect(() => update({ type: "diff", path: "/p/a.ts", oldText: "a", newText: "b" })).toThrow( "An ACP v2 client cannot take a v1 diff", ); - expect(() => update({ type: "terminal", terminalId: "term" })).toThrow( - "does not translate terminal tool call content yet", - ); + expect(update({ type: "terminal", terminalId: "term" })).toMatchObject({ + content: [{ type: "terminal", terminalId: "term" }], + }); }); it("keeps the audience roles of a prompt block that v1 knows", () => { diff --git a/src/tool-calls/client-capabilities.ts b/src/tool-calls/client-capabilities.ts index 880f83e2..ba771f13 100644 --- a/src/tool-calls/client-capabilities.ts +++ b/src/tool-calls/client-capabilities.ts @@ -28,7 +28,9 @@ export class ClientCapabilities { /** * The client speaks ACP v2. Its diffs carry structured `changes` and an * optional git patch, which a v1 diff cannot express. Only the v2 surface - * sets this, never the capabilities that a client sends. + * sets this, never the capabilities that a client sends. A v2 client also + * takes the terminal extension (`terminalOutput`), which the v2 surface + * sends as display terminals. */ readonly v2: boolean = false, ) {} @@ -40,7 +42,7 @@ export class ClientCapabilities { const meta = capabilities?._meta; const terminalOutputDelta = meta?.["terminal_output_delta"] === true; return new ClientCapabilities( - terminalOutputDelta || meta?.["terminal_output"] === true, + v2 || terminalOutputDelta || meta?.["terminal_output"] === true, terminalOutputDelta, clientSupportsAirCapability(capabilities, AIR_DIFF_PATCH_CAPABILITY), { diff --git a/src/v2/agent.ts b/src/v2/agent.ts index d6b9470d..e112921e 100644 --- a/src/v2/agent.ts +++ b/src/v2/agent.ts @@ -29,6 +29,7 @@ import { v2ResumeSessionResponse, } from "./session.js"; import { v2SessionUpdate } from "./session-update.js"; +import { V2Terminals } from "./terminal.js"; /** * The ACP v2 surface, for one connection. @@ -67,7 +68,11 @@ export function v2AgentApp( : await client.replaying(params.sessionId, () => agent.loadSession(restore.request)), ); }) - .onRequest(v2.methods.agent.session.close, ({ params }) => agent.closeSession(params)) + .onRequest(v2.methods.agent.session.close, async ({ params }) => { + const response = await agent.closeSession(params); + client.terminals.forget(params.sessionId); + return response; + }) .onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params)) .onRequest(v2.methods.agent.session.setConfigOption, async ({ params }) => { const { configOptions, ...response } = await agent.setSessionConfigOption( @@ -94,6 +99,9 @@ class V2ClientConnection implements AcpClient { */ private readonly replays = new Map>(); + /** The display terminals of the commands that the agent runs. */ + readonly terminals = new V2Terminals(); + constructor( private readonly ctx: v2.AgentContext, private readonly logger: Logger, @@ -139,17 +147,34 @@ class V2ClientConnection implements AcpClient { } async sessionUpdate({ update, ...notification }: AcpSessionNotification): Promise { - const v2Update = v2SessionUpdate(update); - if (!v2Update) return; - const clear = this.replayClear(notification.sessionId, v2Update); - // Both go to the connection before either is awaited, so a chunk that - // another call sends meanwhile cannot come between them. - await Promise.all([ - ...(clear - ? [this.ctx.notify(v2.methods.client.session.update, { ...notification, update: clear })] - : []), - this.ctx.notify(v2.methods.client.session.update, { ...notification, update: v2Update }), - ]); + const updates = this.v2Updates(notification.sessionId, update).flatMap((v2Update) => { + const clear = this.replayClear(notification.sessionId, v2Update); + return clear ? [clear, v2Update] : [v2Update]; + }); + // Every update goes to the connection before any is awaited, so an update + // that another call sends meanwhile cannot come between them. + await Promise.all( + updates.map((v2Update) => + this.ctx.notify(v2.methods.client.session.update, { ...notification, update: v2Update }), + ), + ); + } + + /** + * The v2 updates of a v1 update: for a tool call report, the updates of its + * terminal first, then the report itself (see {@link V2Terminals}). + */ + private v2Updates( + sessionId: string, + update: AcpSessionNotification["update"], + ): v2.SessionUpdate[] { + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + const v2Update = v2SessionUpdate(update); + return v2Update ? [v2Update] : []; + } + const { terminal, report } = this.terminals.split(sessionId, update); + const reportUpdate = report && v2SessionUpdate(report); + return [...terminal, ...(reportUpdate ? [reportUpdate] : [])]; } /** diff --git a/src/v2/terminal.ts b/src/v2/terminal.ts new file mode 100644 index 00000000..8c46cd5a --- /dev/null +++ b/src/v2/terminal.ts @@ -0,0 +1,131 @@ +/** + * Agent-owned display terminals for the commands that the agent runs. + * + * The agent reports a command through the terminal extension of ACP v1 + * clients, as codex-acp does: `_meta.terminal_info` names the terminal of a + * tool call, whose content references it; `terminal_output` (a snapshot) or + * `terminal_output_delta` (a chunk) carries the output, and `terminal_exit` the + * exit. ACP v2 standardizes this as display-only terminals: the same content + * reference, and `terminal_update` and `terminal_output_chunk` for the + * terminal's state. The agent reports to a v2 client as to a client of the + * extension (`ToolCallClientCapabilities.from`), and {@link V2Terminals} turns + * the extension's keys into those updates. + */ +import type { ToolCall, ToolCallUpdate } from "@agentclientprotocol/sdk"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2"; + +type V1ToolCallReport = + | (ToolCall & { sessionUpdate: "tool_call" }) + | (ToolCallUpdate & { sessionUpdate: "tool_call_update" }); + +/** The `_meta` keys of the terminal extension. */ +interface TerminalMeta { + terminal_info?: { terminal_id: string }; + terminal_output?: { terminal_id: string; data: string }; + terminal_output_delta?: { terminal_id: string; data: string }; + terminal_exit?: { terminal_id: string; exit_code: number; signal: string | null }; +} + +const TERMINAL_KEYS = [ + "terminal_info", + "terminal_output", + "terminal_output_delta", + "terminal_exit", +] as const; + +/** The terminal updates of the tool call reports of one v2 connection. */ +export class V2Terminals { + /** The terminal and the last reported command of each running command, by session and tool call. */ + private readonly running = new Map(); + + /** + * The terminal updates that a tool call report carries in the extension's + * keys, and the report without them, or undefined when nothing else is left + * in it. + * + * The extension names no command, so the terminal gets the command of the + * tool call's `rawInput` once a report carries it: the first report of a + * streamed tool use has no input yet. + */ + split( + sessionId: string, + report: V1ToolCallReport, + ): { terminal: v2.SessionUpdate[]; report: V1ToolCallReport | undefined } { + const meta = (report._meta ?? {}) as TerminalMeta; + const key = `${sessionId} ${report.toolCallId}`; + if (meta.terminal_info) this.running.set(key, { terminalId: meta.terminal_info.terminal_id }); + const terminal: v2.SessionUpdate[] = []; + const running = this.running.get(key); + if (running) { + const command = commandOf(report.rawInput); + const newCommand = command !== undefined && command !== running.command; + if (newCommand) running.command = command; + const { terminal_output: output, terminal_output_delta: delta, terminal_exit: exit } = meta; + if (meta.terminal_info || newCommand || output || exit) { + terminal.push({ + sessionUpdate: "terminal_update", + terminalId: running.terminalId, + ...(newCommand ? { command } : {}), + ...(output ? { output: { data: base64(output.data) } } : {}), + ...(exit + ? { + exitStatus: { + exitCode: exit.exit_code, + ...(exit.signal != null ? { signal: exit.signal } : {}), + }, + } + : {}), + }); + } + if (delta) { + terminal.push({ + sessionUpdate: "terminal_output_chunk", + terminalId: running.terminalId, + data: base64(delta.data), + }); + } + if (exit) this.running.delete(key); + } + return { terminal, report: withoutTerminalKeys(report) }; + } + + /** Forgets the terminals of a session. */ + forget(sessionId: string): void { + for (const key of this.running.keys()) { + if (key.startsWith(`${sessionId} `)) this.running.delete(key); + } + } +} + +/** + * The report without the extension's keys, or undefined when it carried + * nothing else but `_meta`: the output report of a command carries the output + * alone, and only the stamps that every report of the tool call repeats. In v2 + * a concrete `_meta` replaces the stored one, so sending them would drop the + * other keys that the tool call holds. + */ +function withoutTerminalKeys(report: V1ToolCallReport): V1ToolCallReport | undefined { + if (!report._meta || !TERMINAL_KEYS.some((key) => key in report._meta!)) return report; + const meta = Object.fromEntries( + Object.entries(report._meta).filter( + ([key]) => !(TERMINAL_KEYS as readonly string[]).includes(key), + ), + ); + const stripped = { ...report }; + if (Object.keys(meta).length > 0) stripped._meta = meta; + else delete stripped._meta; + const fields = Object.keys(stripped).filter( + (key) => key !== "sessionUpdate" && key !== "toolCallId" && key !== "_meta", + ); + return fields.length > 0 || report.sessionUpdate === "tool_call" ? stripped : undefined; +} + +function commandOf(rawInput: unknown): string | undefined { + const command = (rawInput as { command?: unknown } | null | undefined)?.command; + return typeof command === "string" && command.length > 0 ? command : undefined; +} + +/** The output bytes, base64-encoded as a v2 terminal carries them. */ +function base64(text: string): string { + return Buffer.from(text, "utf8").toString("base64"); +} diff --git a/src/v2/tool-call.ts b/src/v2/tool-call.ts index 59623266..a1df40aa 100644 --- a/src/v2/tool-call.ts +++ b/src/v2/tool-call.ts @@ -23,8 +23,9 @@ type V2ToolCallUpdate = v2.ToolCallUpdate & { sessionUpdate: "tool_call_update" * * Diffs need what a v1 diff does not say, so the agent builds them in v2 form * for a v2 client (`v2DiffContent`), and they pass as they are. Terminal - * content throws until the v2 surface translates it (step 4 of - * `docs/acp-v2.md`). + * content references a display terminal, as in v2: a v2 client has no client + * terminals, so the agent's only terminals are those of its terminal extension + * (see `terminal.ts`). */ export function v2ToolCallUpdate(report: V1ToolCallReport): V2ToolCallUpdate { return { ...v2ToolCallPatch(report), sessionUpdate: "tool_call_update" }; @@ -51,13 +52,13 @@ function v2ToolCallContent(item: ToolCallContent): v2.ToolCallContent { case "content": // v2 content blocks are a superset of the v1 blocks. return item; - case "diff": + case "terminal": + return item; + default: if (isV2Diff(item)) return item; // A v1 diff names one path, with old and new texts that can be a // snippet: v2 cannot say which operation it is, nor give its patch. throw new Error("An ACP v2 client cannot take a v1 diff"); - default: - throw new Error(`The ACP v2 surface does not translate ${item.type} tool call content yet`); } }