diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl new file mode 100644 index 00000000000..38dd3136218 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl @@ -0,0 +1,12 @@ +{"at": 1, "kind": "meta", "scenario": "auth-failed", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 288, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "39446645-6ec0-4807-9e18-95ac04c13143", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"} +{"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "b8e8b60c-1592-4f05-a21a-853f6a1a5040", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 302, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "queued", "uuid": "17b4b010-4615-4bf0-9b63-7936336cd7c4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 306, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "started", "uuid": "b1ed2141-c5a2-4c7e-8403-dbeb53d15be2", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 319, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "61043fb2-ec0d-4652-9ab8-00483ac5f026", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 321, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ad41aad3-ab9d-4b3c-a9f2-8610044d9482"}} +{"at": 342, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "timestamp": "2026-09-29T10:33:13.793Z", "isReplay": true}} +{"at": 342, "kind": "frame", "frame": {"type": "assistant", "message": {"diagnostics": null, "id": "58f67b61-a560-4d83-b099-4f1b2e920de8", "container": null, "model": "", "role": "assistant", "stop_details": null, "stop_reason": "stop_sequence", "stop_sequence": "", "type": "message", "usage": {"output_tokens_details": null, "input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": null, "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": null, "iterations": null, "speed": null}, "content": [{"type": "text", "text": "Not logged in \u00b7 Please run /login"}], "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "6c5de4d1-e149-4201-b18b-b0021d4650f2", "timestamp": "2026-09-29T10:33:13.809Z", "error": "authentication_failed", "is_api_error_message": true, "user_message_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "user_message_uuids": ["39446645-6ec0-4807-9e18-95ac04c13143"]}} +{"at": 346, "kind": "frame", "frame": {"duration_api_ms": 0, "stop_reason": "stop_sequence", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {}, "permission_denials": [], "terminal_reason": "api_error", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "Not logged in \u00b7 Please run /login", "type": "result", "duration_ms": 36, "uuid": "9736f712-8292-41be-bd62-305633c7e152", "user_message_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "user_message_uuids": ["39446645-6ec0-4807-9e18-95ac04c13143"], "queued_turn_count": 0, "result_index": 0}} +{"at": 347, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "cancelled", "uuid": "64028711-21fb-4a47-99c9-8ae448e4c577", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 347, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "081649ae-6232-43d6-b31b-6d1da6167020", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl new file mode 100644 index 00000000000..50cab7abe66 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl @@ -0,0 +1,28 @@ +{"at": 15, "kind": "meta", "scenario": "batch-lead", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 334, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "adafe449-4ded-495b-a4db-b82589e901e6", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "9d3d1c58-6d69-408a-ae73-73d5c9656e9f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 376, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "adafe449-4ded-495b-a4db-b82589e901e6", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "30ed6f57-a819-4c93-b4d3-7d76838c1950", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 410, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 413, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "2dfef2b1-c08e-4a66-94b9-c3bbbe8d6adb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 413, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "queued", "uuid": "378fac6f-7ffb-417f-b9d3-4311c3d99d64", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 414, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "started", "uuid": "4bdc9a15-e613-42f3-a912-f6bcf8712999", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 474, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "b3869f1d-27b1-488a-a8a1-bde1a52fbf84", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 477, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "1e0b56e7-60ab-430d-910f-06c0337fb209"}} +{"at": 1478, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "timestamp": "2026-09-29T10:36:14.355Z", "isReplay": true}} +{"at": 1478, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "state": "queued", "uuid": "9438512a-5380-48da-8ddf-6adffc28cfbb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "dispatch", "clientMessageId": "client-C", "sentUuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "text": "Reply with text only; do not use any tools. Reply with exactly: C-DONE"} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "queued", "uuid": "4f1b992a-26e8-43c5-af40-c3e5ef5707c8", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "control", "request": {"subtype": "cancel_async_message", "message_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8"}} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "state": "cancelled", "uuid": "f52f03d1-df02-432c-8bb6-c94062877e6f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "control-answer", "response": {"cancelled": true}} +{"at": 10848, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed02", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "89d623dc-6a78-46fc-9123-ee06e2d61a6b", "timestamp": "2026-09-29T10:36:24.740Z", "request_id": "req_scrubbed01", "user_message_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "user_message_uuids": ["0e1f8935-c61a-480b-8877-72c10dd1adda"]}} +{"at": 10885, "kind": "frame", "frame": {"duration_api_ms": 11864, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.0398048, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "output_tokens": 1497, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 1497, "cache_read_input_tokens": 23004, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1211, "outputTokens": 1518, "cacheReadInputTokens": 23004, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.0398048, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred", "ttft_ms": 10419, "type": "result", "duration_ms": 10453, "uuid": "922da184-ba96-4a9e-a133-27dd7f45818f", "ttft_stream_ms": 1063, "time_to_request_ms": 71, "first_content_frame_ms": 1063, "user_message_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "user_message_uuids": ["0e1f8935-c61a-480b-8877-72c10dd1adda"], "request_sent_wall_ms": 1790678174393, "queued_turn_count": 0, "result_index": 0}} +{"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "completed", "uuid": "3df6d400-c72e-4521-92f4-cc286ab007b7", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "started", "uuid": "e7087639-8080-4803-b8e4-638cc31888e5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "6fce32e9-e653-477f-b6b6-4d10267b2991", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e546544b-76be-4d78-9094-9a8329a6a756"}} +{"at": 12309, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: C-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "timestamp": "2026-09-29T10:36:24.795Z", "isReplay": true}} +{"at": 12310, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed03", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "C-DONE"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 9027, "cache_read_input_tokens": 15502, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 9027}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "63316a0d-face-40ab-b7c5-a2f3c7aef73b", "timestamp": "2026-09-29T10:36:26.216Z", "request_id": "req_scrubbed02", "user_message_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "user_message_uuids": ["19ec4a51-3c43-4197-9305-f7e6e5ae96c5"]}} +{"at": 12331, "kind": "frame", "frame": {"duration_api_ms": 13267, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.11527720000000001, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 9027, "cache_read_input_tokens": 15502, "output_tokens": 7, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 9027, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 4, "output_tokens": 7, "cache_read_input_tokens": 15502, "cache_creation_input_tokens": 9027, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 9027}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1215, "outputTokens": 1525, "cacheReadInputTokens": 38506, "cacheCreationInputTokens": 9027, "webSearchRequests": 0, "costUSD": 0.11527720000000001, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "C-DONE", "ttft_ms": 1424, "type": "result", "duration_ms": 1438, "uuid": "fb897d43-901b-4f47-a701-d4d5f4cf8562", "ttft_stream_ms": 1423, "time_to_request_ms": 23, "first_content_frame_ms": 1423, "user_message_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "user_message_uuids": ["19ec4a51-3c43-4197-9305-f7e6e5ae96c5"], "request_sent_wall_ms": 1790678184816, "queued_turn_count": 0, "result_index": 1}} +{"at": 12331, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "completed", "uuid": "bccf83ae-642e-42f8-8a33-1448870fb137", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 12332, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "5277a376-53d0-4a5d-b6f6-7669baccb91f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl new file mode 100644 index 00000000000..69284b8119d --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl @@ -0,0 +1,19 @@ +{"at": 0, "kind": "meta", "scenario": "cancel-async", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 207, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "6c03b0d7-8119-4015-bd43-9c83fa47119b", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "ce5037b8-9e84-4b39-a598-cc4cea7123b4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 225, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "6c03b0d7-8119-4015-bd43-9c83fa47119b", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "93e4cdab-4976-4646-a5ce-9544936bfe6e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 256, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 261, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "3746879b-3bb8-47f8-a371-9544bc5812a5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "queued", "uuid": "a75f748a-f917-4f8a-afea-28f022859753", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "started", "uuid": "e204ce9a-ee8a-4b64-ba12-eff1d33c52d1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 301, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "dad169fc-bced-4e8f-a40f-f3ea0537c763", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "f33c5acc-1608-453c-86a0-2568002a67d0"}} +{"at": 1096, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "timestamp": "2026-09-29T10:35:47.486Z", "isReplay": true}} +{"at": 1097, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 1097, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "state": "queued", "uuid": "a021a2ec-59b0-4470-ab40-5c3af8b9dcb0", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1097, "kind": "control", "request": {"subtype": "cancel_async_message", "message_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded"}} +{"at": 1098, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "state": "cancelled", "uuid": "75187135-edfc-4738-8890-3c5204d319f1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1098, "kind": "control-answer", "response": {"cancelled": true}} +{"at": 10491, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed02", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "16c7fded-8293-4480-a708-bae2361dc27c", "timestamp": "2026-09-29T10:35:57.675Z", "request_id": "req_scrubbed01", "user_message_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "user_message_uuids": ["f3cd976f-dc3f-4eca-8622-ebf4e5b1d679"]}} +{"at": 10532, "kind": "frame", "frame": {"duration_api_ms": 12016, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.0398048, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "output_tokens": 1497, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 1497, "cache_read_input_tokens": 23004, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1211, "outputTokens": 1518, "cacheReadInputTokens": 23004, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.0398048, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred", "ttft_ms": 10214, "type": "result", "duration_ms": 10247, "uuid": "7aa2d6f4-d1dd-4749-ae09-7b0235246b51", "ttft_stream_ms": 834, "time_to_request_ms": 47, "first_content_frame_ms": 834, "user_message_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "user_message_uuids": ["f3cd976f-dc3f-4eca-8622-ebf4e5b1d679"], "request_sent_wall_ms": 1790678147508, "queued_turn_count": 0, "result_index": 0}} +{"at": 10532, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "completed", "uuid": "28426f29-739b-48dc-94e8-63e85e197192", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10533, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "b8e0b928-4ded-4ba4-8a88-5a0c5517657a", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl new file mode 100644 index 00000000000..35230188f88 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl @@ -0,0 +1,20 @@ +{"at": 3, "kind": "meta", "scenario": "interrupt-lost", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 333, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "a8c39655-a598-42a7-943e-899b511643d9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 362, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "1bc17c32-c0d8-4c1d-ace0-03327bbcf13b", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 416, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 421, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "ef60bf56-2954-4f1a-bfaf-502f4cf7e5f9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 421, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "queued", "uuid": "8f4de258-0da7-4ff7-a972-24c5d2b9e593", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 422, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "started", "uuid": "e70550c4-18ff-4795-9ddf-9dd267e82b9e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 488, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "31b93145-d113-4141-adb1-2174b07845cd", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 490, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "38344e0c-d00c-40dd-9da2-6cd73d7ad800"}} +{"at": 2520, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "timestamp": "2026-09-29T10:35:25.741Z", "isReplay": true}} +{"at": 3136, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "d15945e1-6168-458e-9b2d-49f427327af2", "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda"}} +{"at": 3137, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 3137, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "queued", "uuid": "f6865348-9b42-410c-96e8-5bf01e1d68de", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3137, "kind": "control", "request": {"subtype": "interrupt", "cancel_queued": true}} +{"at": 3138, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "cancelled", "uuid": "d4cf2dfc-2530-4fa6-9f47-efab0874177a", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3138, "kind": "control-answer", "response": {"still_queued": [], "cancelled": ["0ca6fc96-94c3-4462-9524-a8341729ab91"]}} +{"at": 3139, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "[Request interrupted by user]"}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ce4fe796-391f-4acc-b37c-49b668682592", "timestamp": "2026-09-29T10:35:28.422Z"}} +{"at": 3149, "kind": "frame", "frame": {"duration_api_ms": 2176, "stop_reason": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.005256, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1209, "outputTokens": 21, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.005256, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "aborted_streaming", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 2, "subtype": "error_during_execution", "errors": ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"], "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "type": "result", "duration_ms": 2717, "uuid": "9551c994-3a32-4f9a-b205-be27ae648b93", "user_message_uuids": ["d0649b29-11d5-45e7-9034-6fb789e78eda"], "queued_turn_count": 0, "result_index": 0}} +{"at": 3150, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "cancelled", "uuid": "24526d3c-00cc-488a-b899-239756190772", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3150, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "5de7c47b-2d4f-4f66-b301-d5e4de20e787", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts new file mode 100644 index 00000000000..c5570e476fd --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -0,0 +1,424 @@ +// Replays of real Claude CLI 2.1.280 sessions (`__fixtures__/claude-lifecycle-capture-*.jsonl`) +// in which the CLI withdrew a queued send, interrupted a turn, or failed one. The recorded +// frames are the script; at each recorded control request the test decides how Orca's side of +// it went — the answer arriving, lost, or failing — and the CLI's own frames from while that +// request was outstanding are delivered either way. + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import type { ClaudeLateDispatchOutcome } from './claude-structured-session-state' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID, + type FakeConnection +} from './claude-structured-session-test-support' + +type CapturedEvent = + | { kind: 'meta'; providerSessionId: string } + | { kind: 'frame'; frame: Record } + | { kind: 'dispatch'; clientMessageId: string; sentUuid: string; text: string } + | { kind: 'control'; request: Record } + | { kind: 'control-answer'; response: Record } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodeCapturedEvent(value: unknown): CapturedEvent { + if (!isRecord(value)) { + throw new Error('capture line is not a recorded event') + } + if (value.kind === 'meta' && typeof value.providerSessionId === 'string') { + return { kind: 'meta', providerSessionId: value.providerSessionId } + } + if (value.kind === 'frame' && isRecord(value.frame)) { + return { kind: 'frame', frame: value.frame } + } + if ( + value.kind === 'dispatch' && + typeof value.clientMessageId === 'string' && + typeof value.sentUuid === 'string' && + typeof value.text === 'string' + ) { + return { + kind: 'dispatch', + clientMessageId: value.clientMessageId, + sentUuid: value.sentUuid, + text: value.text + } + } + if (value.kind === 'control' && isRecord(value.request)) { + return { kind: 'control', request: value.request } + } + if (value.kind === 'control-answer' && isRecord(value.response)) { + return { kind: 'control-answer', response: value.response } + } + throw new Error(`capture line has unknown kind: ${String(value.kind)}`) +} + +function loadCapture(name: string): CapturedEvent[] { + const path = join(__dirname, '__fixtures__', `claude-lifecycle-capture-${name}.jsonl`) + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .map((line) => decodeCapturedEvent(JSON.parse(line))) +} + +type Settlement = { sessionId: string } & ClaudeLateDispatchOutcome + +type ControlPoint = { + request: Record + /** The CLI's answer as recorded, mapped to this replay's uuids. */ + answer: Record + /** Delivers the frames the CLI emitted while the request was outstanding. */ + deliverInFlight: () => void + adapter: ClaudeStructuredSessionAdapter + connection: FakeConnection + routes: ReturnType['routes'] + liveUuid: (clientMessageId: string) => string +} + +async function replayCapture( + name: string, + options: { + /** What Orca did at the recorded control request; by default nothing (the CLI acted alone). */ + atControl?: (point: ControlPoint) => Promise + /** Drop these from the capture's init frames, as an older CLI would not advertise them. */ + withoutCapabilities?: string[] + /** Leave out captured frames, to model a sequence the capture brackets. */ + omitFrame?: (frame: Record) => boolean + /** Frames to deliver right after a captured one, in the capture's own uuids. */ + afterFrame?: (frame: Record) => Record[] + /** Stop after the control request settles; its tail answers the CLI's own control path. */ + stopAfterControl?: boolean + } = {} +) { + const capture = loadCapture(name) + const settlements: Settlement[] = [] + const idles: string[] = [] + const turnStates = new Map() + // The capture supplies every frame, startup proof included. + const claude = fakeClaude({ initProof: 'none', replayUuid: null }) + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1, + now: () => 1_700_000_200_000, + persistHandle: async () => {}, + onDispatchSettledLate: (settlement) => settlements.push(settlement), + onSessionIdle: ({ sessionId }) => idles.push(sessionId) + }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + turnStates.set(turn.turnId, turn.state) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + }) + const connection = claude.connections[0]! + + // Captured uuids -> the uuids the live dispatches mint during this replay. + const uuidMap = new Map() + const capturedSessionId = capture.flatMap((event) => + event.kind === 'meta' ? [event.providerSessionId] : [] + )[0]! + const mapUuids = (value: Record): Record => { + let text = JSON.stringify(value).replaceAll(capturedSessionId, PROVIDER_SESSION_ID) + for (const [captured, live] of uuidMap) { + text = text.replaceAll(captured, live) + } + const mapped: unknown = JSON.parse(text) + if (!isRecord(mapped)) { + throw new Error('mapped frame is not a record') + } + return mapped + } + const deliver = (frame: Record): void => { + if (options.omitFrame?.(frame)) { + return + } + const mapped = mapUuids(frame) + if (Array.isArray(mapped.capabilities) && options.withoutCapabilities) { + mapped.capabilities = mapped.capabilities.filter( + (capability) => !options.withoutCapabilities!.includes(String(capability)) + ) + } + connection.handlers.onMessage?.(mapped) + for (const extra of options.afterFrame?.(frame) ?? []) { + connection.handlers.onMessage?.(mapUuids(extra)) + } + } + const liveUuid = (clientMessageId: string): string => { + const dispatch = capture.find( + (event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId + ) + return dispatch?.kind === 'dispatch' ? (uuidMap.get(dispatch.sentUuid) ?? '') : '' + } + + let proofDelivered = false + for (let index = 0; index < capture.length; index++) { + const event = capture[index]! + if (event.kind === 'frame') { + deliver(event.frame) + proofDelivered = true + } else if (event.kind === 'dispatch') { + if (proofDelivered) { + await adapter.awaitStarted('session-1') + } + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: event.clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: event.text }] }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + uuidMap.set(event.sentUuid, String(connection.sent.at(-1)!.uuid)) + } else if (event.kind === 'control') { + const answerAt = capture.findIndex( + (candidate, at) => at > index && candidate.kind === 'control-answer' + ) + if (answerAt === -1) { + throw new Error('capture recorded no answer for its control request') + } + const inFlight = capture.slice(index + 1, answerAt) + const answer = capture[answerAt] + let delivered = false + const deliverInFlight = (): void => { + if (!delivered) { + delivered = true + for (const frameEvent of inFlight) { + if (frameEvent.kind === 'frame') { + deliver(frameEvent.frame) + } + } + } + } + const atControl = options.atControl ?? (async (point) => point.deliverInFlight()) + await atControl({ + request: mapUuids(event.request), + answer: answer?.kind === 'control-answer' ? mapUuids(answer.response) : {}, + deliverInFlight, + adapter, + connection, + routes: claude.routes, + liveUuid + }) + deliverInFlight() + if (options.stopAfterControl) { + break + } + index = answerAt + } + } + const settlementsFor = (clientMessageId: string) => + settlements + .filter((settlement) => settlement.clientMessageId === clientMessageId) + .map(({ sessionId: _sessionId, clientMessageId: _id, ...outcome }) => outcome) + return { settlementsFor, idles, turnStates, liveUuid, connection, adapter } +} + +const WITHDRAWN = { + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } +} + +function acceptedAs(uuid: string) { + return { providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid } } +} + +/** Orca's Stop with no turn named, as the chat sends it; the interrupt answers per `answer`. */ +function stopWithInterruptAnswer(answer: 'recorded' | 'lost') { + return async (point: ControlPoint): Promise => { + point.routes.interrupt = () => { + point.deliverInFlight() + if (answer === 'lost') { + throw new ClaudeControlRequestError('interrupt', 'Control request timed out') + } + return point.answer + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + } +} + +describe('a Stop whose interrupt cancelled a queued follow-up (cancel_queued)', () => { + it.each(['recorded', 'lost'] as const)( + 'withdraws the follow-up once when the interrupt answer is %s, and leaves the stopped turn’s message accepted', + async (answer) => { + const replay = await replayCapture('interrupt-lost', { + atControl: stopWithInterruptAnswer(answer) + }) + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: { cancelQueued: true } } + ]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + // The interrupted turn's own message was echoed first; its later `cancelled` changes nothing. + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + } + ) + + it('ignores a cancelled frame for a send it no longer holds, or never sent', async () => { + const replay = await replayCapture('interrupt-lost', { + atControl: stopWithInterruptAnswer('lost') + }) + for (const commandUuid of [ + replay.liveUuid('client-A'), + replay.liveUuid('client-B'), + '5a1b0c63-0000-4000-8000-000000000000' + ]) { + replay.connection.handlers.onMessage?.({ + type: 'command_lifecycle', + command_uuid: commandUuid, + state: 'cancelled', + uuid: `lifecycle-${commandUuid}`, + session_id: PROVIDER_SESSION_ID + }) + } + + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + }) +}) + +describe('a Stop that withdraws the follow-up one at a time (no cancel_queued)', () => { + it.each([ + [ + 'times out', + () => { + throw new ClaudeControlRequestError('cancel_async_message', 'Control request timed out') + } + ], + [ + 'errors', + () => { + throw new Error('Query closed before response received') + } + ], + ['answers false', () => false] + ] as const)( + 'withdraws it from the CLI’s cancelled frame when cancel_async_message %s', + async (_label, answer) => { + const replay = await replayCapture('cancel-async', { + withoutCapabilities: ['interrupt_cancel_queued_v1'], + stopAfterControl: true, + atControl: async (point) => { + const queued = point.liveUuid('client-B') + point.routes.interrupt = () => ({ still_queued: [queued] }) + point.routes.cancel_async_message = () => { + point.deliverInFlight() + return answer() + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + } + }) + + expect(replay.connection.calls.map((call) => call.subtype)).toContain('cancel_async_message') + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + } + ) +}) + +describe('a queued send the CLI withdraws without Orca hearing why', () => { + it('settles the withdrawn send from its own cancelled frame', async () => { + const replay = await replayCapture('cancel-async') + + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + }) + + it('withdraws only the batch lead; the send behind it runs as its own turn and is accepted', async () => { + const replay = await replayCapture('batch-lead') + + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))]) + expect([...replay.turnStates.entries()]).toEqual([ + [replay.liveUuid('client-A'), 'completed'], + [replay.liveUuid('client-C'), 'completed'] + ]) + }) +}) + +describe('a send the CLI started, then cancelled', () => { + it('stays accepted when its turn failed after the echo', async () => { + const replay = await replayCapture('auth-failed') + + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + }) + + it('is not read as withdrawn when the cancelled frame came before any echo', async () => { + // The auth-failed capture with the turn's output removed: started, then cancelled. + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' + }) + + expect(replay.settlementsFor('client-A')).toEqual([]) + }) + + it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result', + afterFrame: (frame) => + frame.type === 'command_lifecycle' && frame.state === 'started' + ? [{ ...frame, state: 'queued' }] + : [] + }) + + expect(replay.settlementsFor('client-A')).toEqual([]) + }) +}) + +describe('the CLI reporting its session idle', () => { + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'is reported once per idle frame (%s), never for running', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.idles).toEqual(['session-1']) + } + ) + + it('is not reported from a child the session no longer holds', async () => { + const replay = await replayCapture('auth-failed') + await replay.adapter.closeSession('session-1') + + replay.connection.handlers.onMessage?.({ + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'late-idle', + session_id: PROVIDER_SESSION_ID + }) + + expect(replay.idles).toEqual(['session-1']) + }) +}) diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts new file mode 100644 index 00000000000..652a431d538 --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.ts @@ -0,0 +1,33 @@ +// What Claude's per-command `command_lifecycle` frames (msg_lifecycle_v1) settle. +// +// `cancelled` is not by itself a withdrawal: a command the CLI already started also ends +// `cancelled` when its turn is interrupted or fails (measured on 2.1.280). Only a command +// cancelled before it started was withdrawn. That frame lands ahead of the control answer, so +// it settles the send even when the interrupt or cancel_async_message answer is lost. + +import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import { readClaudeFrameString } from './claude-structured-init-proof' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' +import type { ClaudeSession } from './claude-structured-session-state' + +export function observeClaudeCommandLifecycle( + session: ClaudeSession, + message: Record, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const commandUuid = readClaudeFrameString(message, 'command_uuid') + // An echoed send has left both lists, so nothing here can reach a delivered message. + const waiter = [...session.dispatchWaiters, ...session.retiredDispatchWaiters].find( + (candidate) => candidate.sentUuid === commandUuid + ) + if (!waiter) { + return + } + const state = message.state + if (state === 'started' || (state === 'queued' && waiter.commandLifecycle !== 'started')) { + // Forward only: a redelivered command re-emits `queued`, but it has still started. + waiter.commandLifecycle = state + } else if (state === 'cancelled' && waiter.commandLifecycle !== 'started') { + settleCancelledClaudeDispatchWaiters(session, [waiter.sentUuid], onSettledLate) + } +} diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index d34d0efc206..9db40feac49 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -3,8 +3,10 @@ // A replay or result is joined to its waiter by the client uuid Claude echoes. // A send Claude FOLDS into the running request cycle is replayed mid-cycle with // the client uuid adopted: once that cycle has done work, that replay is a -// delivery receipt and opens no boundary. +// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; its +// `cancelled` can settle a send the CLI withdrew (`claude-command-lifecycle.ts`). +import { observeClaudeCommandLifecycle } from './claude-command-lifecycle' import { forgetRetiredWaiter } from './claude-structured-dispatch-waiters' import { claudeHasReplayContent, @@ -28,6 +30,10 @@ export function resolveClaudeReplayTurn( message: Record, onSettledLate?: ClaudeLateDispatchSettlement ): ClaudeReplayTurnOrigin | null { + if (message.type === 'command_lifecycle') { + observeClaudeCommandLifecycle(session, message, onSettledLate) + return null + } const envelope = readClaudeMessageEnvelope(message) const isUserReplay = envelope?.role === 'user' && diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index cd8836e4b82..b4adbfccfa5 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -8,6 +8,7 @@ import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' +import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { readClaudeCapabilities, readClaudeFrameString, @@ -117,6 +118,10 @@ export async function acquireClaudeSession({ if (message.type === 'result' && sessions.get(sessionId) === liveSession) { persistClaudeTurnResumePoint(sessionId, liveSession, deps) } + // The CLI idles only once its queue drains, so it holds none of this session's sends. + if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { + deps.onSessionIdle?.({ sessionId }) + } } // Settled after the turn this echo opens is emitted: a send read as answered before its turn // lands reads as nothing running, and Stop and Working blink off in between. diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 82332f72383..b40aaba81c4 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -95,6 +95,8 @@ export type ClaudeStructuredSessionAdapterDeps = { onEvent?: (event: ClaudeStructuredSessionEvent) => void /** Direct settlement path for provider-proven late dispatch outcomes. */ onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void + /** The CLI reported `session_state_changed idle`, which it sends only once its queue drains. */ + onSessionIdle?: (input: { sessionId: string }) => void onBackgroundTasksChanged?: ( sessionId: string, state: AgentSessionBackgroundTaskState | null @@ -139,6 +141,8 @@ export type ClaudeDispatchWaiter = { settledUuid?: string /** The write failed or the child died, but a replay may still name it. */ retired?: boolean + /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ + commandLifecycle?: 'queued' | 'started' /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ replayContentKey: string } diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts new file mode 100644 index 00000000000..becdc9f2b38 --- /dev/null +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -0,0 +1,108 @@ +// A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as +// work still owed. The CLI reports `session_state_changed idle` only once its queue has drained, +// so that report retires the doubt; a send whose dispatch is still `pending` is left alone. +// Against the production runtime, adapter, record store and host, with only the CLI scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import { DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-idle-release' +const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) +const CALLER = { callerKey: 'client-1' } + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +function fence(host: StructuredAgentSessionHost): number { + return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 +} + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence(host), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function sessionState(state: 'running' | 'idle'): Record { + return { + type: 'system', + subtype: 'session_state_changed', + state, + uuid: `state-${state}`, + session_id: PROVIDER_SESSION + } +} + +describe('a live Claude chat whose CLI reports idle', () => { + it('releases a send whose write ended in doubt, and leaves a pending one alone', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + // Handed to the SDK, then the write's outcome was lost. + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + throw new Error('stdin write stalled') + } + const doubted = await send(host, 'first') + await vi.waitFor(async () => + expect(await submission(host, doubted)).toMatchObject({ dispatchState: 'unknown' }) + ) + // Written, never echoed: its dispatch has returned, but nothing has settled it. + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + } + const pending = await send(host, 'second') + await vi.waitFor(async () => + expect(await submission(host, pending)).toMatchObject({ dispatchState: 'pending' }) + ) + + child.handlers.onMessage?.(sessionState('running')) + await new Promise((resolve) => setTimeout(resolve, 50)) + // POSITIVE CONTROL: only idle releases it. + expect((await submission(host, doubted))?.recovered).toBeUndefined() + + child.handlers.onMessage?.(sessionState('idle')) + + await vi.waitFor(async () => + expect(await submission(host, doubted)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + // The write's own doubt is the sharper fact, so it survives the release. + reason: `${DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN}: stdin write stalled` + }) + ) + expect(await submission(host, pending)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(host, pending))?.recovered).toBeUndefined() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 1659fcd512e..10db8ba3bda 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -215,6 +215,20 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + void host + ?.releaseUnansweredDispatches({ + sessionId, + reason: DISPATCH_DOUBT_PROVIDER_IDLE + }) + .catch((error) => + deps.onError?.({ + scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, + error + }) + ) + } const codex = new CodexStructuredSessionAdapter({ resolveLaunch: createCodexStructuredLaunchResolver({ store, @@ -233,19 +247,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, - onPrimaryThreadStoppedRunning: ({ sessionId }) => { - void host - ?.releaseUnansweredDispatches({ - sessionId, - reason: DISPATCH_DOUBT_PROVIDER_IDLE - }) - .catch((error) => - deps.onError?.({ - scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, - error - }) - ) - }, + onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, onEvent: (event) => { if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { lifecycle.deliver(event) @@ -274,6 +276,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, + onSessionIdle: releaseUnansweredDispatches, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9e4c9aca2c7..761a6835a55 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -40,6 +40,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { state: AgentSessionBackgroundTaskState | null ) => void onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] + onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] } @@ -132,6 +133,7 @@ export function createStructuredClaudeRuntimeAdapter( ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } : {}), ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), + ...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}),