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`); } }