From d16aecb08b9da0f9d0141e9a175a34fb4415506e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:55:27 -0700 Subject: [PATCH 01/27] fix(claude): settle a queued send the CLI withdrew from its own cancelled frame Claude reports each uuid-stamped command's lifecycle (queued, started, completed, cancelled). A send it withdraws from its queue gets `cancelled` before the interrupt or cancel_async_message answer, so a lost or failed answer no longer leaves that send pending: it settles as withdrawn, with the same reason and words as the receipt path. A command the CLI already started also ends `cancelled` when its turn is interrupted or fails, so `cancelled` after `started` is not a withdrawal; an echoed send has left the waiter lists and is never reached. Tests replay real 2.1.280 captures, scrubbed. --- ...claude-lifecycle-capture-auth-failed.jsonl | 12 + .../claude-lifecycle-capture-batch-lead.jsonl | 28 ++ ...laude-lifecycle-capture-cancel-async.jsonl | 19 + ...ude-lifecycle-capture-interrupt-lost.jsonl | 20 + .../claude/claude-command-lifecycle.test.ts | 378 ++++++++++++++++++ src/main/claude/claude-command-lifecycle.ts | 32 ++ .../claude/claude-replay-turn-resolution.ts | 8 +- .../claude/claude-structured-session-state.ts | 2 + 8 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl create mode 100644 src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl create mode 100644 src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl create mode 100644 src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl create mode 100644 src/main/claude/claude-command-lifecycle.test.ts create mode 100644 src/main/claude/claude-command-lifecycle.ts 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 000000000000..76062add5725 --- /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_scrubbed01_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 000000000000..59b0db148ea5 --- /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_scrubbed01_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_scrubbed01_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 000000000000..a133cf2523e9 --- /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_scrubbed01_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 000000000000..4242490be096 --- /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_scrubbed01_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 000000000000..09a639702a7b --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -0,0 +1,378 @@ +// 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 + /** 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 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) + }) + 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) + } + 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, 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([]) + }) +}) diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts new file mode 100644 index 000000000000..b22ad2185f26 --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.ts @@ -0,0 +1,32 @@ +// 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 === 'queued' || state === '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 d34d0efc206c..9db40feac49e 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-state.ts b/src/main/claude/claude-structured-session-state.ts index 6848d9a19437..6a6b4a033311 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -139,6 +139,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 } From 44612449b68e1aa9b8f59c7d809cf5136315ac7a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:56:15 -0700 Subject: [PATCH 02/27] fix(claude): release a doubted send when the CLI reports its session idle A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as work still owed, so the chat showed Working until the child exited. Claude sends `session_state_changed idle` only once its whole queue has drained, so it can no longer be holding that send. The runtime now routes that report to the host's existing release, the same one Codex's thread-stopped report uses; it retires `unknown` only, never `pending`. --- .../claude/claude-command-lifecycle.test.ts | 32 +++++- .../claude-structured-session-acquisition.ts | 5 + .../claude/claude-structured-session-state.ts | 2 + ...e-structured-idle-releases-unknown.test.ts | 108 ++++++++++++++++++ .../structured-agent-session-runtime.ts | 29 ++--- .../structured-claude-runtime-adapter.ts | 2 + 6 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 src/main/runtime/claude-structured-idle-releases-unknown.test.ts diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 09a639702a7b..1f3befff71fa 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -100,6 +100,7 @@ async function replayCapture( ) { 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 }) @@ -118,7 +119,8 @@ async function replayCapture( readProcessStartTime: async () => 1, now: () => 1_700_000_200_000, persistHandle: async () => {}, - onDispatchSettledLate: (settlement) => settlements.push(settlement) + onDispatchSettledLate: (settlement) => settlements.push(settlement), + onSessionIdle: ({ sessionId }) => idles.push(sessionId) }) await adapter.acquire({ identity: identityFor(), @@ -232,7 +234,7 @@ async function replayCapture( settlements .filter((settlement) => settlement.clientMessageId === clientMessageId) .map(({ sessionId: _sessionId, clientMessageId: _id, ...outcome }) => outcome) - return { settlementsFor, turnStates, liveUuid, connection, adapter } + return { settlementsFor, idles, turnStates, liveUuid, connection, adapter } } const WITHDRAWN = { @@ -376,3 +378,29 @@ describe('a send the CLI started, then cancelled', () => { 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-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index cd8836e4b826..b4adbfccfa5c 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 6a6b4a033311..b2e1ea5bf1b1 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 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 000000000000..becdc9f2b389 --- /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 1659fcd512ea..10db8ba3bda4 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 4160b81c4dea..b2367e1aff09 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'] } @@ -130,6 +131,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 } : {}), From 45212a991007320bd51290c24c3ba94a49c7df0c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:09:48 -0700 Subject: [PATCH 03/27] fix(claude): keep a command's started mark when a redelivery re-emits queued; fixtures name msg_lifecycle_v1 --- .../claude-lifecycle-capture-auth-failed.jsonl | 2 +- .../claude-lifecycle-capture-batch-lead.jsonl | 4 ++-- ...claude-lifecycle-capture-cancel-async.jsonl | 2 +- ...aude-lifecycle-capture-interrupt-lost.jsonl | 2 +- .../claude/claude-command-lifecycle.test.ts | 18 ++++++++++++++++++ src/main/claude/claude-command-lifecycle.ts | 3 ++- 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl index 76062add5725..38dd3136218b 100644 --- a/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl @@ -3,7 +3,7 @@ {"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_scrubbed01_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": 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"]}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl index 59b0db148ea5..50cab7abe664 100644 --- a/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl @@ -5,7 +5,7 @@ {"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_scrubbed01_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": 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"} @@ -19,7 +19,7 @@ {"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_scrubbed01_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": "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"]}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl index a133cf2523e9..69284b8119d5 100644 --- a/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl @@ -5,7 +5,7 @@ {"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_scrubbed01_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": 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"} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl index 4242490be096..35230188f882 100644 --- a/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl @@ -5,7 +5,7 @@ {"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_scrubbed01_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": 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"}} diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 1f3befff71fa..c5570e476fd1 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -94,6 +94,8 @@ async function replayCapture( 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 } = {} @@ -166,6 +168,9 @@ async function replayCapture( ) } 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( @@ -377,6 +382,19 @@ describe('a send the CLI started, then cancelled', () => { 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', () => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index b22ad2185f26..652a431d5383 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -24,7 +24,8 @@ export function observeClaudeCommandLifecycle( return } const state = message.state - if (state === 'queued' || state === 'started') { + 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) From 15078d126cd2dfcda552885c03c9727b05284dd7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:25:58 -0700 Subject: [PATCH 04/27] fix(claude): settle every terminal lifecycle state of a send the CLI never echoed A send the CLI started, then cancelled before any echo, stayed pending: it may already be in the conversation, so it is released as doubt (unknown, recovered), never withdrawn and never re-sent. A late echo still accepts it. The 2.1.280 schema has two more terminal states. `discarded` (the CLI ended its session with the send still queued) settles as not delivered; `refused` (declined before it queued) settles as not accepted by the provider. After `started`, either one is doubt, as `cancelled` is. The late-settlement path gains an `unknown` outcome, which the host records as released doubt. --- .../claude/claude-command-lifecycle.test.ts | 97 ++++++++++++++++--- src/main/claude/claude-command-lifecycle.ts | 45 +++++++-- src/main/claude/claude-structured-dispatch.ts | 47 +++++++-- .../claude/claude-structured-session-state.ts | 2 + .../journal-dispatch-doubt-reasons.ts | 3 + ...structured-agent-session-host-mutations.ts | 26 +++-- ...e-structured-idle-releases-unknown.test.ts | 58 ++++++++++- 7 files changed, 242 insertions(+), 36 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index c5570e476fd1..7d276478a026 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -10,7 +10,10 @@ 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 { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { claudeDispatchRejection } from './claude-structured-dispatch-content' import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import type { ClaudeLateDispatchOutcome } from './claude-structured-session-state' import { @@ -96,6 +99,8 @@ async function replayCapture( omitFrame?: (frame: Record) => boolean /** Frames to deliver right after a captured one, in the capture's own uuids. */ afterFrame?: (frame: Record) => Record[] + /** Deliver this in place of a captured frame, as a CLI reporting another state would. */ + rewriteFrame?: (frame: Record) => Record /** Stop after the control request settles; its tail answers the CLI's own control path. */ stopAfterControl?: boolean } = {} @@ -161,7 +166,7 @@ async function replayCapture( if (options.omitFrame?.(frame)) { return } - const mapped = mapUuids(frame) + const mapped = mapUuids(options.rewriteFrame?.(frame) ?? frame) if (Array.isArray(mapped.capabilities) && options.withoutCapabilities) { mapped.capabilities = mapped.capabilities.filter( (capability) => !options.withoutCapabilities!.includes(String(capability)) @@ -366,6 +371,25 @@ describe('a queued send the CLI withdraws without Orca hearing why', () => { }) }) +/** The auth-failed capture's turn output: without it, its send started and got no echo. */ +function isTurnOutput(frame: Record): boolean { + return frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' +} + +function isLifecycleFrame(frame: Record, state?: string): boolean { + return frame.type === 'command_lifecycle' && (state === undefined || frame.state === state) +} + +/** The uuid a capture recorded for one of its sends, as its own frames name it. */ +function capturedUuid(name: string, clientMessageId: string): string { + const dispatch = loadCapture(name).find( + (event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId + ) + return dispatch?.kind === 'dispatch' ? dispatch.sentUuid : '' +} + +const ENDED_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } + 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') @@ -373,27 +397,72 @@ describe('a send the CLI started, then cancelled', () => { 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' - }) + it('is released as doubt, never withdrawn, when the cancelled frame came before any echo', async () => { + const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) - expect(replay.settlementsFor('client-A')).toEqual([]) + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) }) 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', + omitFrame: isTurnOutput, afterFrame: (frame) => - frame.type === 'command_lifecycle' && frame.state === 'started' - ? [{ ...frame, state: 'queued' }] - : [] + isLifecycleFrame(frame, 'started') ? [{ ...frame, state: 'queued' }] : [] + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + }) + + it('is still accepted when its echo lands after the doubt', async () => { + const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) + const uuid = replay.liveUuid('client-A') + + replay.connection.handlers.onMessage?.({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'late echo' }] }, + parent_tool_use_id: null, + session_id: PROVIDER_SESSION_ID, + uuid + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT, acceptedAs(uuid)]) + }) + + it.each(['discarded', 'refused'])( + 'is released as doubt when its turn ends %s after it started', + async (state) => { + const replay = await replayCapture('auth-failed', { + omitFrame: isTurnOutput, + rewriteFrame: (frame) => + isLifecycleFrame(frame, 'cancelled') ? { ...frame, state } : frame + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + } + ) +}) + +describe('a send the CLI ended before starting it', () => { + it.each([ + // The CLI ended its session with the send still queued. + ['discarded', 'notDelivered', false], + // Declined before it queued, so no `queued` frame precedes it. + ['refused', 'providerRejected', true] + ] as const)('settles a %s send as not sent, saying why', async (state, kind, neverQueued) => { + const followUp = capturedUuid('cancel-async', 'client-B') + const replay = await replayCapture('cancel-async', { + omitFrame: (frame) => + neverQueued && isLifecycleFrame(frame, 'queued') && frame.command_uuid === followUp, + rewriteFrame: (frame) => + isLifecycleFrame(frame, 'cancelled') && frame.command_uuid === followUp + ? { ...frame, state } + : frame }) - expect(replay.settlementsFor('client-A')).toEqual([]) + expect(replay.settlementsFor('client-B')).toEqual([ + { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact(kind)) } + ]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) }) }) diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 652a431d5383..365a8b583315 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -1,15 +1,34 @@ // 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. +// A terminal state for a command that never started is a verdict: `cancelled` was withdrawn, +// `discarded` was dropped when the CLI ended its session, `refused` was declined before it +// queued. None ran. Once a command has started, the same states only mean its turn ended — an +// interrupt or a hard failure (measured on 2.1.280) — so it may be in the conversation: doubt. +// These frames land ahead of the control answer, so they settle a send even when that is lost. -import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import { + agentSessionFailureFact, + type SubmissionRejectionKind +} from '../../shared/agent-session-failure' +import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + rejectClaudeDispatchWaiters, + releaseClaudeDispatchWaitersInDoubt +} 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' +const UNSTARTED_VERDICT = { + cancelled: 'cancelled', + discarded: 'notDelivered', + refused: 'providerRejected' +} satisfies Record + +function isTerminalState(state: unknown): state is keyof typeof UNSTARTED_VERDICT { + return state === 'cancelled' || state === 'discarded' || state === 'refused' +} + export function observeClaudeCommandLifecycle( session: ClaudeSession, message: Record, @@ -27,7 +46,19 @@ export function observeClaudeCommandLifecycle( 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) + } else if (isTerminalState(state) && waiter.commandLifecycle === 'started') { + releaseClaudeDispatchWaitersInDoubt( + session, + [waiter], + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + onSettledLate + ) + } else if (isTerminalState(state)) { + rejectClaudeDispatchWaiters( + session, + [waiter.sentUuid], + agentSessionFailureFact(UNSTARTED_VERDICT[state]), + onSettledLate + ) } } diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 612f6391ced5..223ede5550cc 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -7,7 +7,7 @@ import { } from './claude-structured-dispatch-waiters' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import { claudeDispatchContentKey, @@ -18,7 +18,10 @@ import { } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { claudeUnwrittenUserMessageError, @@ -37,10 +40,25 @@ export function settleCancelledClaudeDispatchWaiters( cancelledUuids: readonly string[], onSettledLate?: ClaudeLateDispatchSettlement ): void { - const cancelled = new Set(cancelledUuids) - const activeWaiters = session.dispatchWaiters.filter((waiter) => cancelled.has(waiter.sentUuid)) + rejectClaudeDispatchWaiters( + session, + cancelledUuids, + agentSessionFailureFact('cancelled'), + onSettledLate + ) +} + +/** Settles sends the CLI proved it will never run, each with the fact that says why. */ +export function rejectClaudeDispatchWaiters( + session: ClaudeSession, + uuids: readonly string[], + fact: SubmissionRejectionFact, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const named = new Set(uuids) + const activeWaiters = session.dispatchWaiters.filter((waiter) => named.has(waiter.sentUuid)) const retiredWaiters = session.retiredDispatchWaiters.filter((waiter) => - cancelled.has(waiter.sentUuid) + named.has(waiter.sentUuid) ) for (const waiter of activeWaiters) { forgetWaiter(session, waiter) @@ -54,12 +72,29 @@ export function settleCancelledClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', - ...claudeDispatchRejection(agentSessionFailureFact('cancelled')) + ...claudeDispatchRejection(fact) }) } } } +/** Releases sends the CLI took and let go without an echo or a verdict: each may have run, so each + * settles as doubt, never re-sent. Retired, not dropped, so a late replay still accepts it. */ +export function releaseClaudeDispatchWaitersInDoubt( + session: ClaudeSession, + waiters: readonly ClaudeDispatchWaiter[], + reason: string, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + for (const waiter of waiters) { + retireWaiter(session, waiter) + waiter.resolve(null) + if (waiter.clientMessageId) { + onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'unknown', reason }) + } + } +} + /** Nothing expires a waiter, so the child's death is what ends every live one. * Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index b2e1ea5bf1b1..dcfa9b95f334 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -87,6 +87,8 @@ export type ClaudeLateDispatchOutcome = providerIdentity: AgentJournalItemIdentity } | ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection) + /** The CLI took the send and let it go unanswered: it may have run, so it is never re-sent. */ + | { clientMessageId: string; state: 'unknown'; reason: string } export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index 6c01e6224dc0..1aaa0b76df95 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -27,6 +27,9 @@ export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missin * left that could still acknowledge the message. */ export const DISPATCH_DOUBT_PROVIDER_IDLE = 'provider_idle_before_acknowledgement' +/** The provider started the message, then ended its turn before acknowledging it. */ +export const DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED = 'provider_ended_before_acknowledgement' + /** The SDK took the frame, but its input pump did not prove whether the write completed. */ export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 1586bd4a1c16..74418e98f31a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -264,7 +264,8 @@ export function changeStructuredAgentSessionThreadGoal( ) } -/** Settle provider-proven delivery independently of an in-flight client mutation. */ +/** Settle a provider-proven outcome independently of an in-flight client mutation. `unknown` is + * released doubt: the provider let the send go unanswered, so nothing re-sends it. */ export async function settleStructuredAgentSessionLateDispatch( context: StructuredAgentSessionMutationContext, input: { @@ -273,6 +274,7 @@ export async function settleStructuredAgentSessionLateDispatch( } & ( | { providerIdentity: AgentJournalItemIdentity } | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } ) ): Promise { const session = context.sessions.get(input.sessionId) @@ -289,13 +291,21 @@ export async function settleStructuredAgentSessionLateDispatch( providerIdentity: input.providerIdentity, fence } - : { - clientMessageId: input.clientMessageId, - state: 'rejected', - reason: input.reason, - rejection: input.rejection, - fence - } + : input.state === 'unknown' + ? { + clientMessageId: input.clientMessageId, + state: 'unknown', + reason: input.reason, + fence, + recovered: true + } + : { + clientMessageId: input.clientMessageId, + state: 'rejected', + reason: input.reason, + rejection: input.rejection, + fence + } ) } diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index becdc9f2b389..1ab534f82610 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -6,7 +6,11 @@ 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 { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../shared/structured-agent-session-unanswered-dispatch' 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' @@ -106,3 +110,55 @@ describe('a live Claude chat whose CLI reports idle', () => { expect((await submission(host, pending))?.recovered).toBeUndefined() }) }) + +function lifecycle(commandUuid: string, state: string): Record { + return { + type: 'command_lifecycle', + command_uuid: commandUuid, + state, + uuid: `lifecycle-${state}`, + session_id: PROVIDER_SESSION + } +} + +describe('a live Claude chat whose CLI took a send and let it go without an echo', () => { + it.each([['its turn ended cancelled', 'cancelled', DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED]])( + 'records released doubt when %s', + async (_label, ending, reason) => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) + } + const taken = await send(host, 'retrying') + // The pending row lands before the adapter's write; the write is what names the uuid. + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) + + child.handlers.onMessage?.( + ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) + ) + + await vi.waitFor(async () => + expect(await submission(host, taken)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + reason + }) + ) + // Released doubt: the chat stops reading working, and nothing re-sends it. + expect( + hasUnansweredStructuredAgentSessionDispatch( + (await host.journalSnapshot(SESSION)).submissions + ) + ).toBe(false) + } + ) +}) From 0a36dab302ee38096bdd433063813e36a11b56d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:26:36 -0700 Subject: [PATCH 05/27] fix(claude): release a send the CLI took but left unanswered when it goes idle `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that is still unanswered there got no echo and never will: a turn that throws can leave `started` with no terminal state. Idle releases it as doubt. What proves the CLI took a send is its lifecycle frame. On a CLI that reports no lifecycle, it is the send's place on stdin: one whose write finished before an interrupt went out was read before the interrupt was, so the first idle after that interrupt releases it too. A send armed ahead of the interrupt but written after it is left alone, since the CLI may still run it. --- .../claude/claude-command-lifecycle.test.ts | 98 ++++++++++++++++++- src/main/claude/claude-command-lifecycle.ts | 21 +++- .../claude/claude-replay-turn-resolution.ts | 14 ++- .../claude-structured-control-actions.ts | 9 ++ src/main/claude/claude-structured-dispatch.ts | 3 + .../claude/claude-structured-session-state.ts | 4 + ...e-structured-idle-releases-unknown.test.ts | 75 +++++++------- 7 files changed, 181 insertions(+), 43 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 7d276478a026..03928e834530 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -10,7 +10,10 @@ 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 { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' import { claudeDispatchRejection } from './claude-structured-dispatch-content' @@ -380,6 +383,10 @@ function isLifecycleFrame(frame: Record, state?: string): boole return frame.type === 'command_lifecycle' && (state === undefined || frame.state === state) } +function isIdleFrame(frame: Record): boolean { + return frame.subtype === 'session_state_changed' && frame.state === 'idle' +} + /** The uuid a capture recorded for one of its sends, as its own frames name it. */ function capturedUuid(name: string, clientMessageId: string): string { const dispatch = loadCapture(name).find( @@ -389,6 +396,7 @@ function capturedUuid(name: string, clientMessageId: string): string { } const ENDED_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } +const IDLE_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_IDLE } describe('a send the CLI started, then cancelled', () => { it('stays accepted when its turn failed after the echo', async () => { @@ -466,6 +474,94 @@ describe('a send the CLI ended before starting it', () => { }) }) +describe('a send the CLI took when it goes idle', () => { + it('is released as doubt when its started turn threw with no terminal state', async () => { + // Started, then its turn threw: no echo and no terminal state, as the schema allows. + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + isTurnOutput(frame) || isLifecycleFrame(frame, 'cancelled') || isIdleFrame(frame) + }) + expect(replay.settlementsFor('client-A')).toEqual([]) + + replay.connection.handlers.onMessage?.({ + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'idle-after-throw', + session_id: PROVIDER_SESSION_ID + }) + + expect(replay.settlementsFor('client-A')).toEqual([IDLE_IN_DOUBT]) + }) +}) + +describe('a Stop on a CLI that reports no lifecycle', () => { + const OLDER_CLI = ['msg_lifecycle_v1', 'interrupt_receipt_v1', 'interrupt_cancel_queued_v1'] + const withoutLifecycle = (frame: Record) => isLifecycleFrame(frame) + + it('releases the send the interrupt caught at the next idle, and never one written after it', async () => { + const replay = await replayCapture('interrupt-lost', { + withoutCapabilities: OLDER_CLI, + omitFrame: withoutLifecycle, + atControl: async (point) => { + // Armed ahead of the interrupt, but its write finishes after it. + const send = point.connection.send + const gated = Promise.withResolvers() + const reachedGate = Promise.withResolvers() + point.connection.send = async (message, beforeDispatch) => { + point.connection.send = send + await beforeDispatch?.() + reachedGate.resolve() + await gated.promise + point.connection.sent.push(message) + } + const unwritten = point.adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-D', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'D' }] }, + fence: 7 + }) + await reachedGate.promise + // An older CLI's interrupt answers with no receipt. + point.routes.interrupt = () => { + point.deliverInFlight() + return {} + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + gated.resolve() + await expect(unwritten).resolves.toEqual({ state: 'admitted' }) + await expect( + point.adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-C', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'C' }] }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + } + }) + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: {} } + ]) + expect(replay.settlementsFor('client-B')).toEqual([IDLE_IN_DOUBT]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + // Read after the interrupt, so the CLI may still run them. + expect(replay.settlementsFor('client-C')).toEqual([]) + expect(replay.settlementsFor('client-D')).toEqual([]) + }) + + it('leaves a written send pending at idle when no interrupt came after it', async () => { + const replay = await replayCapture('interrupt-lost', { + withoutCapabilities: OLDER_CLI, + omitFrame: withoutLifecycle + }) + + expect(replay.idles).toEqual(['session-1']) + expect(replay.settlementsFor('client-B')).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', diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 365a8b583315..0140956b5ac3 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -10,7 +10,10 @@ import { agentSessionFailureFact, type SubmissionRejectionKind } from '../../shared/agent-session-failure' -import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { rejectClaudeDispatchWaiters, releaseClaudeDispatchWaitersInDoubt @@ -62,3 +65,19 @@ export function observeClaudeCommandLifecycle( ) } } + +/** + * `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that + * is still unanswered here left without an echo — a turn that threw can leave `started` with no + * terminal state. What proves it took one is its lifecycle frame or, on a CLI with none, its + * position on stdin: written ahead of an interrupt, it was read before the interrupt was. + */ +export function releaseClaudeDispatchesUnansweredAtIdle( + session: ClaudeSession, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const taken = session.dispatchWaiters.filter( + (waiter) => waiter.commandLifecycle !== undefined || waiter.writtenBeforeInterrupt + ) + releaseClaudeDispatchWaitersInDoubt(session, taken, DISPATCH_DOUBT_PROVIDER_IDLE, onSettledLate) +} diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index 9db40feac49e..fe2bb1523f95 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -3,10 +3,14 @@ // 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. A `command_lifecycle` frame opens no turn; its -// `cancelled` can settle a send the CLI withdrew (`claude-command-lifecycle.ts`). +// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; it and the +// CLI going idle settle sends the CLI took but never echoed (`claude-command-lifecycle.ts`). -import { observeClaudeCommandLifecycle } from './claude-command-lifecycle' +import { + observeClaudeCommandLifecycle, + releaseClaudeDispatchesUnansweredAtIdle +} from './claude-command-lifecycle' +import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { forgetRetiredWaiter } from './claude-structured-dispatch-waiters' import { claudeHasReplayContent, @@ -34,6 +38,10 @@ export function resolveClaudeReplayTurn( observeClaudeCommandLifecycle(session, message, onSettledLate) return null } + if (claudeSessionStateEndsTurn(message)) { + releaseClaudeDispatchesUnansweredAtIdle(session, onSettledLate) + return null + } const envelope = readClaudeMessageEnvelope(message) const isUserReplay = envelope?.role === 'user' && diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8ec1aa2e9c41..3ffdcb7eb737 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -6,6 +6,7 @@ import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resoluti import type { ClaudeSession } from './claude-structured-session-state' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' +const COMMAND_LIFECYCLE_CAPABILITY = 'msg_lifecycle_v1' export function supportsClaudeQueuedInterruptCancellation(session: ClaudeSession): boolean { return session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) @@ -33,6 +34,14 @@ export async function cancelClaudeTurn( return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) + // With no lifecycle frames, stdin order is the proof: the CLI reads these before the interrupt. + if (!session.capabilities.includes(COMMAND_LIFECYCLE_CAPABILITY)) { + for (const waiter of session.dispatchWaiters) { + if (waiter.written) { + waiter.writtenBeforeInterrupt = true + } + } + } try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 223ede5550cc..18cb735e5837 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -200,6 +200,9 @@ export async function dispatchClaudeTurn( } return { state: 'unknown', reason: dispatchWriteOutcomeUnknownReason(error) } } + if (pending.replay) { + pending.replay.waiter.written = true + } // The write is the admission signal. Awaiting the echo here would block on the // turn already running, which is why the deadline this replaces kept declaring // doubt about messages that were delivered. The replay resolution diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index dcfa9b95f334..fd3b0ef351a6 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -145,6 +145,10 @@ export type ClaudeDispatchWaiter = { retired?: boolean /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ commandLifecycle?: 'queued' | 'started' + /** The SDK finished writing this send to the CLI's stdin. */ + written?: true + /** Written ahead of an interrupt, so a CLI that reports no lifecycle has read it by its idle. */ + writtenBeforeInterrupt?: true /** 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 index 1ab534f82610..8e6107dce97e 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -8,6 +8,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE, DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { hasUnansweredStructuredAgentSessionDispatch } from '../../shared/structured-agent-session-unanswered-dispatch' @@ -122,43 +123,41 @@ function lifecycle(commandUuid: string, state: string): Record } describe('a live Claude chat whose CLI took a send and let it go without an echo', () => { - it.each([['its turn ended cancelled', 'cancelled', DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED]])( - 'records released doubt when %s', - async (_label, ending, reason) => { - const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ - ok: true - }) - const child = claude.child(SESSION) - let sentUuid = '' - child.connection.send = async (message, beforeDispatch) => { - await beforeDispatch?.() - sentUuid = String(message.uuid) - } - const taken = await send(host, 'retrying') - // The pending row lands before the adapter's write; the write is what names the uuid. - await vi.waitFor(() => expect(sentUuid).not.toBe('')) - expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) - child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) - child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) - - child.handlers.onMessage?.( - ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) - ) - - await vi.waitFor(async () => - expect(await submission(host, taken)).toMatchObject({ - dispatchState: 'unknown', - recovered: true, - reason - }) - ) - // Released doubt: the chat stops reading working, and nothing re-sends it. - expect( - hasUnansweredStructuredAgentSessionDispatch( - (await host.journalSnapshot(SESSION)).submissions - ) - ).toBe(false) + it.each([ + ['its turn ended cancelled', 'cancelled', DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED], + ['it went idle with no terminal state', 'idle', DISPATCH_DOUBT_PROVIDER_IDLE] + ])('records released doubt when %s', async (_label, ending, reason) => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) } - ) + const taken = await send(host, 'retrying') + // The pending row lands before the adapter's write; the write is what names the uuid. + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) + + child.handlers.onMessage?.( + ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) + ) + + await vi.waitFor(async () => + expect(await submission(host, taken)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + reason + }) + ) + // Released doubt: the chat stops reading working, and nothing re-sends it. + expect( + hasUnansweredStructuredAgentSessionDispatch((await host.journalSnapshot(SESSION)).submissions) + ).toBe(false) + }) }) From 2e13528c189ea232abfa5b45fb2f6ca574127327 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:27:23 -0700 Subject: [PATCH 06/27] fix(native-chat): keep the idle sweep off a Claude child that holds a send A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row exists yet and the sweep rested the child after the idle window, turning the send into doubt. The adapter now reports whether the CLI holds a send (lifecycle `queued` or `started`, not yet echoed or ended), derived from the live waiters, and owed work counts it. Nothing is stored: every held send leaves the live set on its echo, its terminal lifecycle state, the CLI's idle, or the child's exit, so the hold ends with the send. --- .../claude/claude-command-lifecycle.test.ts | 16 ++++++++++++++-- src/main/claude/claude-command-lifecycle.ts | 5 +++++ .../claude-structured-session-adapter.ts | 5 +++++ .../structured-agent-session-adapter-router.ts | 3 +++ .../structured-agent-session-adapter.ts | 3 +++ ...ed-agent-session-conversation-close.test.ts | 1 + ...ured-agent-session-conversation-lifetime.ts | 1 + ...structured-agent-session-idle-sweep.test.ts | 18 ++++++++++++++++++ .../structured-agent-session-idle-sweep.ts | 3 +++ .../structured-agent-session-rest-test-rig.ts | 2 ++ ...de-structured-idle-releases-unknown.test.ts | 2 ++ 11 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 03928e834530..9d6f58c39bfd 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -409,6 +409,7 @@ describe('a send the CLI started, then cancelled', () => { const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) }) it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { @@ -474,13 +475,14 @@ describe('a send the CLI ended before starting it', () => { }) }) -describe('a send the CLI took when it goes idle', () => { - it('is released as doubt when its started turn threw with no terminal state', async () => { +describe('a send the CLI holds', () => { + it('holds its child from the idle sweep until the CLI idles, then is released as doubt', async () => { // Started, then its turn threw: no echo and no terminal state, as the schema allows. const replay = await replayCapture('auth-failed', { omitFrame: (frame) => isTurnOutput(frame) || isLifecycleFrame(frame, 'cancelled') || isIdleFrame(frame) }) + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) expect(replay.settlementsFor('client-A')).toEqual([]) replay.connection.handlers.onMessage?.({ @@ -492,7 +494,17 @@ describe('a send the CLI took when it goes idle', () => { }) expect(replay.settlementsFor('client-A')).toEqual([IDLE_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) }) + + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'holds nothing once the %s capture has run to its idle', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + } + ) }) describe('a Stop on a CLI that reports no lifecycle', () => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 0140956b5ac3..7f636724db6e 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -66,6 +66,11 @@ export function observeClaudeCommandLifecycle( } } +/** A send the CLI has taken and neither echoed nor ended: the idle sweep must not rest its child. */ +export function claudeHoldsDispatch(session: ClaudeSession): boolean { + return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle !== undefined) +} + /** * `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that * is still unanswered here left without an echo — a turn that threw can leave `started` with no diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 29b51e730ae9..bd01b4e49d39 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -7,6 +7,7 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { claudeHoldsDispatch } from './claude-command-lifecycle' import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' @@ -230,6 +231,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } readCommands: NonNullable = (sessionId) => this.sessions.get(sessionId)?.commands.commands + holdsDispatch = (sessionId: string): boolean => { + const session = this.sessions.get(sessionId) + return session ? claudeHoldsDispatch(session) : false + } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerClaudeStructuredPrompt({ request, sessions: this.sessions }) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index bb003f93427d..9a759ba0411b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -112,6 +112,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi sessionId ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskState?.(sessionId) + holdsDispatch = (sessionId: string): boolean => + this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false + readCommands: NonNullable = (sessionId) => this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index e4ef8eb85f88..df480721202f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -315,6 +315,9 @@ export type StructuredAgentSessionAdapter = { taskId?: string }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined + /** The provider reported taking a send it has neither answered nor ended, as a silent retry + * does. Derived from the live child; false with none. */ + holdsDispatch?(sessionId: string): boolean /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index bf3cd1f96a60..977b0ff55cfd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -313,6 +313,7 @@ describe('the wind-down retry with a message queued (P2-31)', () => { deliveryActive: () => true, backgroundTaskState: () => undefined, hasOpenDispatch: () => false, + providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, closeConversation: vi.fn(async () => false), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 0fbd1a0a233c..945ddd174558 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -69,6 +69,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { const record = deps().store.getRecord(sessionId) return record !== null && deps().hasOpenDispatch?.(record) === true }, + providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, stopAgent, // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index ebc2009fa7db..90f882fc2f95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -237,6 +237,23 @@ describe('the idle sweep', () => { await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) + // A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row + // exists yet; only the provider can say it still holds the send. + it('never stops an agent while its provider holds a send, and gives a full window once it lets go', async () => { + await foundRestTestChat(rig) + rig.adapter.holdsDispatch.mockReturnValue(true) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(rig.adapter.holdsDispatch).toHaveBeenCalledWith(SESSION) + rig.adapter.holdsDispatch.mockReturnValue(false) + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { await foundRestTestChat(rig) providerEvents().appendItem( @@ -300,6 +317,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { deliveryActive: () => false, backgroundTaskState: () => undefined, hasOpenDispatch: () => false, + providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, closeConversation, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 2394b604171c..04e5744ff639 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -30,6 +30,8 @@ export type StructuredAgentSessionIdleSweepDeps = { backgroundTaskState: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined /** An orchestration dispatch that still owns this session's worker; derived from its database. */ hasOpenDispatch: (sessionId: string) => boolean + /** The provider holds a send it took and has not answered; see `holdsDispatch`. */ + providerHoldsDispatch: (sessionId: string) => boolean /** Each of these runs inside the session's serialize and never takes it again. */ stopAgent: (sessionId: string) => Promise stopStartingAgent: (sessionId: string) => Promise @@ -145,6 +147,7 @@ export class StructuredAgentSessionIdleSweep { this.queuedOrDelivering(sessionId, session) || agentChildWorkLiveness(this.deps.backgroundTaskState(sessionId)?.tasks) !== null || this.deps.hasOpenDispatch(sessionId) || + this.deps.providerHoldsDispatch(sessionId) || hasPendingStructuredAgentSessionPrompt(items) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 3ea116fa361d..105fffe941fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -43,6 +43,7 @@ export type RestTestAdapter = { NonNullable > backgroundTaskState: Mock> + holdsDispatch: Mock> readOptions: Mock> } @@ -147,6 +148,7 @@ export async function createRestTestRig( dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), backgroundTaskState: vi.fn(() => undefined), + holdsDispatch: vi.fn(() => false), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } const hostFor = (overrides: Partial) => diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 8e6107dce97e..34b24787d966 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -143,6 +143,7 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) child.handlers.onMessage?.( ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) @@ -155,6 +156,7 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo reason }) ) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(false) // Released doubt: the chat stops reading working, and nothing re-sends it. expect( hasUnansweredStructuredAgentSessionDispatch((await host.journalSnapshot(SESSION)).submissions) From 86813491c7d78bcbd17f03b6bf3fc0df81d1f840 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:45:13 -0700 Subject: [PATCH 07/27] fix(claude): count only a started send at idle and as a held send 2.1.280's end-of-turn cleanup can report idle before it re-reads its queue, so a send read in that window goes queued, idle, started. Releasing every taken send at idle doubted that live send and dropped Working. Only a `started` send is released at idle or keeps the child from the idle sweep; a `queued` one ends by starting and echoing, by a terminal lifecycle frame, or with the child. The stdin-order path for CLIs without lifecycle frames is removed: a doubted send retired there disables content matching on CLIs that mint their own echo ids, and no Orca failure called for it. Those CLIs keep the earlier behaviour. Comments that said only a failed write or child exit ends a waiter, or that idle comes only once the queue has drained, now say what ends one. --- .../claude/claude-command-lifecycle.test.ts | 102 ++++++------------ src/main/claude/claude-command-lifecycle.ts | 16 ++- .../claude-structured-control-actions.ts | 9 -- .../claude-structured-dispatch-waiters.ts | 6 +- src/main/claude/claude-structured-dispatch.ts | 7 +- .../claude-structured-session-acquisition.ts | 2 +- .../claude/claude-structured-session-state.ts | 6 +- .../structured-agent-session-adapter.ts | 2 +- ...e-structured-idle-releases-unknown.test.ts | 4 +- 9 files changed, 52 insertions(+), 102 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 9d6f58c39bfd..0c9a3da9f882 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -497,81 +497,49 @@ describe('a send the CLI holds', () => { expect(replay.adapter.holdsDispatch('session-1')).toBe(false) }) - it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( - 'holds nothing once the %s capture has run to its idle', - async (name) => { - const replay = await replayCapture(name) - - expect(replay.adapter.holdsDispatch('session-1')).toBe(false) - } - ) -}) - -describe('a Stop on a CLI that reports no lifecycle', () => { - const OLDER_CLI = ['msg_lifecycle_v1', 'interrupt_receipt_v1', 'interrupt_cancel_queued_v1'] - const withoutLifecycle = (frame: Record) => isLifecycleFrame(frame) - - it('releases the send the interrupt caught at the next idle, and never one written after it', async () => { - const replay = await replayCapture('interrupt-lost', { - withoutCapabilities: OLDER_CLI, - omitFrame: withoutLifecycle, - atControl: async (point) => { - // Armed ahead of the interrupt, but its write finishes after it. - const send = point.connection.send - const gated = Promise.withResolvers() - const reachedGate = Promise.withResolvers() - point.connection.send = async (message, beforeDispatch) => { - point.connection.send = send - await beforeDispatch?.() - reachedGate.resolve() - await gated.promise - point.connection.sent.push(message) - } - const unwritten = point.adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-D', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'D' }] }, - fence: 7 - }) - await reachedGate.promise - // An older CLI's interrupt answers with no receipt. - point.routes.interrupt = () => { - point.deliverInFlight() - return {} - } - await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) - gated.resolve() - await expect(unwritten).resolves.toEqual({ state: 'admitted' }) - await expect( - point.adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-C', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'C' }] }, - fence: 7 - }) - ).resolves.toEqual({ state: 'admitted' }) - } + // 2.1.280's end-of-turn cleanup can report idle before it re-reads its queue. + it('leaves a queued send the CLI idles before starting to open its turn and be accepted', async () => { + const replay = await replayCapture('auth-failed', { + afterFrame: (frame) => + isLifecycleFrame(frame, 'queued') + ? [ + { + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'idle-before-start', + session_id: frame.session_id + } + ] + : [] }) + const uuid = replay.liveUuid('client-A') - expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ - { subtype: 'interrupt', params: {} } - ]) - expect(replay.settlementsFor('client-B')).toEqual([IDLE_IN_DOUBT]) - expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) - // Read after the interrupt, so the CLI may still run them. - expect(replay.settlementsFor('client-C')).toEqual([]) - expect(replay.settlementsFor('client-D')).toEqual([]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(uuid)]) + expect(replay.turnStates.get(uuid)).toBe('completed') }) - it('leaves a written send pending at idle when no interrupt came after it', async () => { - const replay = await replayCapture('interrupt-lost', { - withoutCapabilities: OLDER_CLI, - omitFrame: withoutLifecycle + it('does not hold the child for a send the CLI has only queued, nor release it at idle', async () => { + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + isTurnOutput(frame) || + isLifecycleFrame(frame, 'started') || + isLifecycleFrame(frame, 'cancelled') }) expect(replay.idles).toEqual(['session-1']) - expect(replay.settlementsFor('client-B')).toEqual([]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + expect(replay.settlementsFor('client-A')).toEqual([]) }) + + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'holds nothing once the %s capture has run to its idle', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + } + ) }) describe('the CLI reporting its session idle', () => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 7f636724db6e..1336954b1f00 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -66,23 +66,19 @@ export function observeClaudeCommandLifecycle( } } -/** A send the CLI has taken and neither echoed nor ended: the idle sweep must not rest its child. */ +/** A send the CLI started and neither echoed nor ended: the idle sweep must not rest its child. */ export function claudeHoldsDispatch(session: ClaudeSession): boolean { - return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle !== undefined) + return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle === 'started') } /** - * `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that - * is still unanswered here left without an echo — a turn that threw can leave `started` with no - * terminal state. What proves it took one is its lifecycle frame or, on a CLI with none, its - * position on stdin: written ahead of an interrupt, it was read before the interrupt was. + * At idle, only a `started` send still unanswered is doubt: a turn that threw leaves it with no + * terminal state. A `queued` one may still start; the CLI can idle before it re-reads its queue. */ export function releaseClaudeDispatchesUnansweredAtIdle( session: ClaudeSession, onSettledLate?: ClaudeLateDispatchSettlement ): void { - const taken = session.dispatchWaiters.filter( - (waiter) => waiter.commandLifecycle !== undefined || waiter.writtenBeforeInterrupt - ) - releaseClaudeDispatchWaitersInDoubt(session, taken, DISPATCH_DOUBT_PROVIDER_IDLE, onSettledLate) + const started = session.dispatchWaiters.filter((waiter) => waiter.commandLifecycle === 'started') + releaseClaudeDispatchWaitersInDoubt(session, started, DISPATCH_DOUBT_PROVIDER_IDLE, onSettledLate) } diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 3ffdcb7eb737..8ec1aa2e9c41 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -6,7 +6,6 @@ import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resoluti import type { ClaudeSession } from './claude-structured-session-state' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' -const COMMAND_LIFECYCLE_CAPABILITY = 'msg_lifecycle_v1' export function supportsClaudeQueuedInterruptCancellation(session: ClaudeSession): boolean { return session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) @@ -34,14 +33,6 @@ export async function cancelClaudeTurn( return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) - // With no lifecycle frames, stdin order is the proof: the CLI reads these before the interrupt. - if (!session.capabilities.includes(COMMAND_LIFECYCLE_CAPABILITY)) { - for (const waiter of session.dispatchWaiters) { - if (waiter.written) { - waiter.writtenBeforeInterrupt = true - } - } - } try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), diff --git a/src/main/claude/claude-structured-dispatch-waiters.ts b/src/main/claude/claude-structured-dispatch-waiters.ts index 92b6a5048b2f..2c5df3c671eb 100644 --- a/src/main/claude/claude-structured-dispatch-waiters.ts +++ b/src/main/claude/claude-structured-dispatch-waiters.ts @@ -13,8 +13,10 @@ export function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispat * A waiter with no deadline. A mid-turn send Claude folds into the running turn * is replayed mid-turn; one it runs later is replayed only when its own turn * starts — an interval bounded only by the previous turn. Elapsed time is - * therefore not evidence about delivery, and nothing here expires. Waiters are - * retired by process facts instead: a failed write, or child exit. + * therefore not evidence about delivery, and nothing here expires. Waiters end + * by provider and process facts instead: an echo, a `command_lifecycle` frame, + * the CLI's idle after `started` (`claude-command-lifecycle.ts`), a failed + * write, or child exit. */ export function waitForReplay( session: ClaudeSession, diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 18cb735e5837..d98ad4119ecd 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -95,8 +95,8 @@ export function releaseClaudeDispatchWaitersInDoubt( } } -/** Nothing expires a waiter, so the child's death is what ends every live one. - * Retired rather than dropped: their identities stay joinable, bounded by +/** Nothing expires a waiter; the child's death ends every one its echo, a lifecycle frame or the + * CLI's idle has not. Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { failClaudeStartup(session, new Error('claude stream-json ended before startup completed')) @@ -200,9 +200,6 @@ export async function dispatchClaudeTurn( } return { state: 'unknown', reason: dispatchWriteOutcomeUnknownReason(error) } } - if (pending.replay) { - pending.replay.waiter.written = true - } // The write is the admission signal. Awaiting the echo here would block on the // turn already running, which is why the deadline this replaces kept declaring // doubt about messages that were delivered. The replay resolution diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index b4adbfccfa5c..0a78e270ee10 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -118,7 +118,7 @@ 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. + // The CLI's idle re-derives the doubted sends it can no longer answer. if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { deps.onSessionIdle?.({ sessionId }) } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index fd3b0ef351a6..29b7594fb9d9 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -97,7 +97,7 @@ 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. */ + /** The CLI reported `session_state_changed idle`: its turn is over. */ onSessionIdle?: (input: { sessionId: string }) => void onBackgroundTasksChanged?: ( sessionId: string, @@ -145,10 +145,6 @@ export type ClaudeDispatchWaiter = { retired?: boolean /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ commandLifecycle?: 'queued' | 'started' - /** The SDK finished writing this send to the CLI's stdin. */ - written?: true - /** Written ahead of an interrupt, so a CLI that reports no lifecycle has read it by its idle. */ - writtenBeforeInterrupt?: true /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ replayContentKey: string } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index df480721202f..db27f972dbc6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -315,7 +315,7 @@ export type StructuredAgentSessionAdapter = { taskId?: string }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined - /** The provider reported taking a send it has neither answered nor ended, as a silent retry + /** The provider reported starting a send it has neither answered nor ended, as a silent retry * does. Derived from the live child; false with none. */ holdsDispatch?(sessionId: string): boolean /** The `/` surface the running provider reports for itself. Undefined when the diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 34b24787d966..5a03c505826a 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -1,6 +1,6 @@ // 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. +// work still owed. The CLI reporting `session_state_changed idle` retires the doubt; a send whose +// dispatch is still `pending` is left alone unless the CLI had started it. // Against the production runtime, adapter, record store and host, with only the CLI scripted. import { afterEach, describe, expect, it, vi } from 'vitest' From a71b3ecdde6b30379bf0c61662909804e733c617 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:50:07 -0700 Subject: [PATCH 08/27] docs(claude): say only what the CLI's lifecycle frames and idle actually prove --- src/main/claude/claude-command-lifecycle.ts | 8 ++++---- src/main/claude/claude-structured-session-acquisition.ts | 2 +- src/main/claude/claude-structured-session-state.ts | 4 ++-- ...tructured-agent-session-unanswered-dispatch-release.ts | 2 +- src/main/runtime/structured-agent-session-runtime.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 1336954b1f00..bd985d64fe08 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -2,9 +2,9 @@ // // A terminal state for a command that never started is a verdict: `cancelled` was withdrawn, // `discarded` was dropped when the CLI ended its session, `refused` was declined before it -// queued. None ran. Once a command has started, the same states only mean its turn ended — an -// interrupt or a hard failure (measured on 2.1.280) — so it may be in the conversation: doubt. -// These frames land ahead of the control answer, so they settle a send even when that is lost. +// queued. None ran. After `started`, 2.1.280 sends only `cancelled`, when an interrupt or a hard +// failure ends the turn (measured), so the send may be in the conversation: any end is doubt. +// An unstarted send's `cancelled` lands before the Stop's answer, so it settles if that is lost. import { agentSessionFailureFact, @@ -73,7 +73,7 @@ export function claudeHoldsDispatch(session: ClaudeSession): boolean { /** * At idle, only a `started` send still unanswered is doubt: a turn that threw leaves it with no - * terminal state. A `queued` one may still start; the CLI can idle before it re-reads its queue. + * terminal state. A `queued` one may still start: 2.1.280's code idles before re-reading its queue. */ export function releaseClaudeDispatchesUnansweredAtIdle( session: ClaudeSession, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 0a78e270ee10..0a160d99b934 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -118,7 +118,7 @@ export async function acquireClaudeSession({ if (message.type === 'result' && sessions.get(sessionId) === liveSession) { persistClaudeTurnResumePoint(sessionId, liveSession, deps) } - // The CLI's idle re-derives the doubted sends it can no longer answer. + // The CLI's idle releases its doubted sends; a late echo still accepts one it goes on to run. if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { deps.onSessionIdle?.({ sessionId }) } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 29b7594fb9d9..ba7432f85452 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -141,7 +141,7 @@ export type ClaudeDispatchWaiter = { requestedAt: number | null /** Set when the provider replay settled this waiter before send returned. */ settledUuid?: string - /** The write failed or the child died, but a replay may still name it. */ + /** Settled with no echo (failed write, child exit, doubt after start); a replay may name it. */ retired?: boolean /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ commandLifecycle?: 'queued' | 'started' @@ -160,7 +160,7 @@ export type ClaudeSession = { acquisitionGeneration: string prompts: ClaudePromptRegistry dispatchWaiters: ClaudeDispatchWaiter[] - /** Bounded identities for dispatches whose child died or whose write failed. */ + /** Bounded identities for dispatches settled without an echo; a late replay still accepts one. */ retiredDispatchWaiters: ClaudeDispatchWaiter[] /** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */ replayContentFallbackBlocked: boolean diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts index 713838c177ec..62078ef853c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts @@ -3,7 +3,7 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session- import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' /** - * Releases sends the provider can no longer be holding. + * Releases doubted sends once the provider reports it has stopped running. * * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of * non-delivery — and a live `unknown` reads as work still owed, so the session diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 10db8ba3bda4..9d1e4eeecce1 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -215,7 +215,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { void host ?.releaseUnansweredDispatches({ From 6f2c729c1a4bacd9159e06958ab558b9bf7d7191 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:11:30 -0700 Subject: [PATCH 09/27] fix(claude): hold the idle sweep while Claude has a send queued, not only started The sweep rested a child whose CLI had queued a follow-up behind a turn, dropping the send it had already taken. The hold now spans the CLI reporting it took the send until its echo, a terminal lifecycle state, or the child's exit. The idle release still covers only started sends: 2.1.280 can report idle before it re-reads its queue. --- .../claude/claude-command-lifecycle.test.ts | 37 ++++++++++++++++++- src/main/claude/claude-command-lifecycle.ts | 4 +- .../structured-agent-session-adapter.ts | 4 +- ...e-structured-idle-releases-unknown.test.ts | 26 +++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 0c9a3da9f882..a1c83ceb1017 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -519,7 +519,7 @@ describe('a send the CLI holds', () => { expect(replay.turnStates.get(uuid)).toBe('completed') }) - it('does not hold the child for a send the CLI has only queued, nor release it at idle', async () => { + it('holds the child for a send the CLI has only queued, and does not release it at idle', async () => { const replay = await replayCapture('auth-failed', { omitFrame: (frame) => isTurnOutput(frame) || @@ -528,10 +528,43 @@ describe('a send the CLI holds', () => { }) expect(replay.idles).toEqual(['session-1']) - expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) expect(replay.settlementsFor('client-A')).toEqual([]) }) + it('holds a queued send until the CLI withdraws it', async () => { + const held: boolean[] = [] + const replay = await replayCapture('cancel-async', { + atControl: async (point) => { + // The lead was echoed already; only the queued follow-up is left. + held.push(point.adapter.holdsDispatch('session-1')) + point.deliverInFlight() + held.push(point.adapter.holdsDispatch('session-1')) + } + }) + + expect(held).toEqual([true, false]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + }) + + it('holds a queued send until it starts and is echoed', async () => { + const held: boolean[] = [] + const replay = await replayCapture('batch-lead', { + // Without its turn's end or the idle, only the start and echo can let it go. + omitFrame: (frame) => + frame.type === 'result' || isLifecycleFrame(frame, 'completed') || isIdleFrame(frame), + atControl: async (point) => { + point.deliverInFlight() + // The batch lead is withdrawn; the send behind it is only queued. + held.push(point.adapter.holdsDispatch('session-1')) + } + }) + + expect(held).toEqual([true]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))]) + }) + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( 'holds nothing once the %s capture has run to its idle', async (name) => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index bd985d64fe08..a5d4408799bd 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -66,9 +66,9 @@ export function observeClaudeCommandLifecycle( } } -/** A send the CLI started and neither echoed nor ended: the idle sweep must not rest its child. */ +/** A send the CLI took, until its echo, a terminal state or exit: the sweep must not rest its child. */ export function claudeHoldsDispatch(session: ClaudeSession): boolean { - return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle === 'started') + return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle !== undefined) } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 28fd59e35d60..e1f41ed9fb58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -334,8 +334,8 @@ export type StructuredAgentSessionAdapter = { taskId?: string }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined - /** The provider reported starting a send it has neither answered nor ended, as a silent retry - * does. Derived from the live child; false with none. */ + /** The provider reported taking a send it has neither answered nor ended, as a queued follow-up + * or a silent retry does. Derived from the live child; false with none. */ holdsDispatch?(sessionId: string): boolean /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 5a03c505826a..9fa9cbfcfa6a 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -162,4 +162,30 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo hasUnansweredStructuredAgentSessionDispatch((await host.journalSnapshot(SESSION)).submissions) ).toBe(false) }) + + it('holds a send it has only queued, through an idle that may precede its start', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) + } + const queued = await send(host, 'behind a turn') + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + // POSITIVE CONTROL: written but not yet taken, nothing holds the child. + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(false) + + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + child.handlers.onMessage?.(sessionState('idle')) + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + expect(await submission(host, queued)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(host, queued))?.recovered).toBeUndefined() + }) }) From ff9d0a45ec3874a487a8b3f3834205d929ce8725 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:30:01 -0700 Subject: [PATCH 10/27] refactor(native-chat): give provider-proven late dispatch settlement its own module --- ...structured-agent-session-host-mutations.ts | 56 +------------------ .../structured-agent-session-host.ts | 2 +- .../structured-agent-session-late-dispatch.ts | 49 ++++++++++++++++ 3 files changed, 52 insertions(+), 55 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 1c5dac3a517f..1b729ad62b8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -7,10 +7,7 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -22,14 +19,10 @@ import type { AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { - agentSessionFailureWords, - type AgentJournalDispatchRejection -} from '../../../shared/agent-session-failure-words' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { admitAndRunAgentSessionMutation, type AgentSessionMutationRequest, @@ -279,51 +272,6 @@ export function changeStructuredAgentSessionThreadGoal( ) } -/** Settle a provider-proven outcome independently of an in-flight client mutation. `unknown` is - * released doubt: the provider let the send go unanswered, so nothing re-sends it. */ -export async function settleStructuredAgentSessionLateDispatch( - context: StructuredAgentSessionMutationContext, - input: { - sessionId: string - clientMessageId: string - } & ( - | { providerIdentity: AgentJournalItemIdentity } - | ({ state: 'rejected' } & AgentJournalDispatchRejection) - | { state: 'unknown'; reason: string } - ) -): Promise { - const session = context.sessions.get(input.sessionId) - if (!session) { - return - } - const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) - // The journal queue drains before close; the host queue would defer this past teardown. - await session.journal.resolveDispatch( - 'providerIdentity' in input - ? { - clientMessageId: input.clientMessageId, - state: 'accepted', - providerIdentity: input.providerIdentity, - fence - } - : input.state === 'unknown' - ? { - clientMessageId: input.clientMessageId, - state: 'unknown', - reason: input.reason, - fence, - recovered: true - } - : { - clientMessageId: input.clientMessageId, - state: 'rejected', - reason: input.reason, - rejection: input.rejection, - fence - } - ) -} - /** The host's thin mutation surface. Each call re-reads the context, so a session * map or fence that moves between calls is never captured by a stale closure. */ export function structuredAgentSessionMutationDelegates( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index efdd83d9f146..6737f7ace002 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -34,9 +34,9 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import * as sessionTabs from './structured-agent-session-host-tabs' import { structuredAgentSessionMutationDelegates, - settleStructuredAgentSessionLateDispatch, type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { settleStructuredAgentSessionLateDispatch } from './structured-agent-session-late-dispatch' import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' import { flushStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts new file mode 100644 index 000000000000..ba0ab9231261 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts @@ -0,0 +1,49 @@ +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' + +/** Settle a provider-proven outcome independently of an in-flight client mutation. `unknown` is + * released doubt: the provider let the send go unanswered, so nothing re-sends it. */ +export async function settleStructuredAgentSessionLateDispatch( + context: StructuredAgentSessionMutationContext, + input: { + sessionId: string + clientMessageId: string + } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } + ) +): Promise { + const session = context.sessions.get(input.sessionId) + if (!session) { + return + } + const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) + // The journal queue drains before close; the host queue would defer this past teardown. + await session.journal.resolveDispatch( + 'providerIdentity' in input + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: input.providerIdentity, + fence + } + : input.state === 'unknown' + ? { + clientMessageId: input.clientMessageId, + state: 'unknown', + reason: input.reason, + fence, + recovered: true + } + : { + clientMessageId: input.clientMessageId, + state: 'rejected', + reason: input.reason, + rejection: input.rejection, + fence + } + ) +} From 5471ca443bf9506b85399190c05a68c5ee2727d5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:37:49 -0700 Subject: [PATCH 11/27] test(claude): pin a steer a Stop interrupts after it started as doubt, not withdrawn --- .../claude/claude-command-lifecycle.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index a1c83ceb1017..f4182a601747 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -451,6 +451,52 @@ describe('a send the CLI started, then cancelled', () => { ) }) +// batch-lead's client-C is a steer: written while A's turn runs, queued, then started as its own +// turn once A completed. Its queued-while-Stopped case is interrupt-lost's client-B, above. +describe('a steer a Stop interrupts after it started', () => { + it('is released as doubt, never withdrawn, when the Stop lands before its echo', async () => { + const steer = capturedUuid('batch-lead', 'client-C') + let started = false + let routes: ControlPoint['routes'] | undefined + const replay = await replayCapture('batch-lead', { + atControl: async (point) => { + routes = point.routes + point.deliverInFlight() + }, + // The Stop lands between the steer's start and its echo: nothing after its start arrives. + omitFrame: () => started, + afterFrame: (frame) => { + started ||= isLifecycleFrame(frame, 'started') && frame.command_uuid === steer + return [] + } + }) + const uuid = replay.liveUuid('client-C') + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) + + // Answered as interrupt-lost's was, with nothing left queued; then that capture's tail for a + // started command a Stop ended (its result dropped: one naming the steer would accept it). + routes!.interrupt = () => ({ still_queued: [], cancelled: [] }) + await replay.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + for (const frame of [ + { + type: 'command_lifecycle', + command_uuid: uuid, + state: 'cancelled', + uuid: 'steer-cancelled' + }, + { type: 'system', subtype: 'session_state_changed', state: 'idle', uuid: 'steer-idle' } + ]) { + replay.connection.handlers.onMessage?.({ ...frame, session_id: PROVIDER_SESSION_ID }) + } + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: { cancelQueued: true } } + ]) + expect(replay.settlementsFor('client-C')).toEqual([ENDED_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + }) +}) + describe('a send the CLI ended before starting it', () => { it.each([ // The CLI ended its session with the send still queued. From 7e867da65e234421d7829e0aad2cc6ae6794633e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:13:02 -0700 Subject: [PATCH 12/27] fix(native-chat): one ordered journal writer Every journal write, streamed or direct, lands in the chat's one write queue in the order it is issued, and has landed in the fold when its call returns (except while an owed import is paid, when it lands in queue order). The event sink stops being a queue ahead of it; journal-write coalescing, which moved a replaced write to the tail, is removed; closing a sink no longer loses writes already handed over. The ten flushStreamedEvents patches go. A streamed text item takes its place at its first delta, so a direct write inside the coalescing window never lands above text already streamed. A Stop interrupts whatever its bookkeeping writes do. --- ...claude-background-task-row-journal.test.ts | 26 +- .../claude-background-task-row-journal.ts | 5 - .../claude-streamed-text-checkpoints.test.ts | 13 +- ...urnal-translation-background-tasks.test.ts | 5 +- ...ude-structured-journal-translation.test.ts | 10 +- .../claude-structured-prompt-ownership.ts | 4 +- ...structured-session-reading-control.test.ts | 5 +- src/main/claude/claude-subagent-group-row.ts | 5 +- .../claude/claude-subagent-roster.test.ts | 6 +- .../codex/codex-structured-item-streams.ts | 7 +- ...codex-structured-journal-generic-frames.ts | 1 - ...dex-structured-journal-goal-resume.test.ts | 5 +- ...red-journal-translation-settlement.test.ts | 6 +- ...ctured-journal-translation-streams.test.ts | 13 +- ...dex-structured-journal-translation.test.ts | 22 +- src/main/codex/codex-subagent-roster.test.ts | 61 ++--- src/main/codex/codex-subagent-roster.ts | 9 +- .../journal-item-appender.ts | 49 +++- .../journal-owed-import-test-support.ts | 112 ++++++++ .../journal-pending-submission-recovery.ts | 22 +- .../agent-session-journal/journal-store.ts | 16 +- .../journal-write-queue.test.ts | 246 +++++++++++++++++ .../journal-write-queue.ts | 77 +++++- .../agent-session-delta-coalescer.test.ts | 41 ++- .../agent-session-delta-coalescer.ts | 15 +- .../provider-turn-activity-routing.test.ts | 3 +- ...ured-agent-session-append-delivery.test.ts | 16 +- ...-agent-session-claude-echo-working.test.ts | 24 +- .../structured-agent-session-command-turn.ts | 4 +- ...ed-agent-session-conversation-stop.test.ts | 32 +-- .../structured-agent-session-delivery-loop.ts | 2 - ...ructured-agent-session-event-sink-queue.ts | 206 ++++++++------ ...tructured-agent-session-event-sink.test.ts | 252 +++++++++++++++--- .../structured-agent-session-event-sink.ts | 75 +++--- .../structured-agent-session-eviction.test.ts | 15 +- .../structured-agent-session-host-delivery.ts | 2 - ...structured-agent-session-host-mutations.ts | 46 ++-- ...ctured-agent-session-host-runtime-state.ts | 4 +- .../structured-agent-session-host.test.ts | 21 -- .../structured-agent-session-host.ts | 5 +- ...ent-session-journal-double-test-support.ts | 40 +++ ...ctured-agent-session-mutation-admission.ts | 2 - ...agent-session-operation-settlement.test.ts | 11 +- ...ctured-agent-session-prompt-cancel.test.ts | 86 ++---- ...ured-agent-session-queued-messages.test.ts | 7 +- ...tructured-agent-session-queued-messages.ts | 6 +- ...ed-agent-session-queued-pause-lift.test.ts | 17 +- .../structured-agent-session-queued-stop.ts | 25 +- .../structured-agent-session-queued-wiring.ts | 1 - ...ent-session-queued-withdrawn-draft.test.ts | 12 +- ...ured-agent-session-resolved-append.test.ts | 73 ++--- ...tructured-agent-session-resolved-append.ts | 77 +++--- .../structured-agent-session-rewind.test.ts | 20 ++ .../structured-agent-session-rewind.ts | 1 - ...red-agent-session-send-idempotency.test.ts | 2 - ...red-agent-session-stop-never-waits.test.ts | 115 ++++++++ ...-agent-session-streamed-text-order.test.ts | 153 +++++++++++ ...ructured-agent-session-thread-goal.test.ts | 14 +- .../structured-agent-session-thread-goal.ts | 5 +- .../structured-agent-session-turns-cancel.ts | 25 +- .../structured-agent-session-turns.test.ts | 85 +++++- .../structured-agent-session-turns.ts | 2 - ...tructured-agent-session-unexpected-exit.ts | 3 +- ...uctured-conversation-command-controller.ts | 5 +- .../structured-conversation-command.ts | 4 +- ...ent-session-codex-settlement-order.test.ts | 210 +++++++++++++++ ...ructured-agent-session-integration.test.ts | 19 +- 67 files changed, 1899 insertions(+), 609 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-write-queue.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-journal-double-test-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts create mode 100644 src/main/runtime/structured-agent-session-codex-settlement-order.test.ts diff --git a/src/main/claude/claude-background-task-row-journal.test.ts b/src/main/claude/claude-background-task-row-journal.test.ts index 736583d03240..287d3aeff9c5 100644 --- a/src/main/claude/claude-background-task-row-journal.test.ts +++ b/src/main/claude/claude-background-task-row-journal.test.ts @@ -111,25 +111,19 @@ describe('Claude background task row journal', () => { expect(appendAndPublish).toHaveBeenCalledTimes(2) }) - it('keeps fallback append coalescing separate from its ordered publication', () => { - const calls: { operation: 'append' | 'publish'; coalescingKey?: string }[] = [] + it('appends the row, then publishes it', () => { + const calls: ('append' | 'publish')[] = [] const task = row() const sink: StructuredAgentSessionEventSink = { appendItem: vi.fn(), appendTombstone: vi.fn(), publish: vi.fn(), - tryAppendResolvedItem: vi.fn((_identity, _body, _resolve, options) => { - calls.push({ - operation: 'append', - ...(options?.coalescingKey ? { coalescingKey: options.coalescingKey } : {}) - }) + tryAppendResolvedItem: vi.fn(() => { + calls.push('append') return { accepted: true as const } }), - tryPublish: vi.fn((options) => { - calls.push({ - operation: 'publish', - ...(options?.coalescingKey ? { coalescingKey: options.coalescingKey } : {}) - }) + tryPublish: vi.fn(() => { + calls.push('publish') return { accepted: true as const } }) } @@ -143,13 +137,7 @@ describe('Claude background task row journal', () => { () => AGENT_JOURNAL_THREAD_SCOPE ) ).toEqual({ accepted: true }) - expect(calls).toEqual([ - { - operation: 'append', - coalescingKey: JSON.stringify(['claude-background-task', 'task-1', 'tool-1']) - }, - { operation: 'publish' } - ]) + expect(calls).toEqual(['append', 'publish']) expect(task.lastSerialized).not.toBeNull() }) diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts index 956bc770f74d..45f8dfc2ae17 100644 --- a/src/main/claude/claude-background-task-row-journal.ts +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -156,12 +156,7 @@ export function writeClaudeBackgroundTaskRow( } beforeAppend?.() const identity = claudeBackgroundTaskIdentity(id, row.generation) - // Generation is translator-local and resets when a provider stream is - // recreated. Keep unresolved writes from distinct provider runs queued side - // by using the provider's parent tool identity as the coalescing discriminator. - const coalescingKey = JSON.stringify(['claude-background-task', id, row.toolUseId ?? null]) const appendOptions = { - coalescingKey, turnScope: turnScope(), ...(lifecycle ? { lifecycle: true } : {}) } diff --git a/src/main/claude/claude-streamed-text-checkpoints.test.ts b/src/main/claude/claude-streamed-text-checkpoints.test.ts index cc196cf21af8..7f8c1c150176 100644 --- a/src/main/claude/claude-streamed-text-checkpoints.test.ts +++ b/src/main/claude/claude-streamed-text-checkpoints.test.ts @@ -88,11 +88,17 @@ describe('claude streamed text checkpoints', () => { it('rewrites a block row with the full text accumulated so far', () => { const { store, rows, runWindow } = checkpoints() + const rest = 'lo, reading the failing test before the fix' store.append(identityOf('block-1'), 'hel') - store.append(identityOf('block-1'), 'lo') + // The first delta makes the row at once; the window folds the rest into one rewrite. + expect(rows).toEqual([{ uuid: 'block-1', text: 'hel' }]) + store.append(identityOf('block-1'), rest) runWindow() - expect(rows).toEqual([{ uuid: 'block-1', text: 'hello' }]) + expect(rows).toEqual([ + { uuid: 'block-1', text: 'hel' }, + { uuid: 'block-1', text: `hel${rest}` } + ]) expect(store.pending).toBe(1) }) @@ -237,11 +243,12 @@ describe('claude streamed text checkpoints', () => { const { store, rows, runWindow } = checkpoints() store.append(identityOf('block-1'), 'text') + store.append(identityOf('block-1'), ' and the rest of it, long enough to checkpoint') store.dispose() runWindow() store.flush() - expect(rows).toEqual([]) + expect(rows).toEqual([{ uuid: 'block-1', text: 'text' }]) expect(store.pending).toBe(0) }) }) diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts index 312eb6d05a27..f55bd0ef3228 100644 --- a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -15,6 +15,7 @@ import { import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { blockOf } from './claude-background-task-row-test-support' +import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' // The frames below are the ones the reported session actually carried: two real // failures that printed THREE red rows whose visible text was the wire opcode, @@ -143,7 +144,7 @@ function persistedTarget( ): StructuredAgentSessionEventTarget { const journal = // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test double implements the journal methods exercised by the deferred sink. - { + withJournalQueueMembers({ appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { persisted.set(agentJournalItemKey(identity), body) return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 } @@ -162,7 +163,7 @@ function persistedTarget( }) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'], itemBody: (itemId: string) => persisted.get(itemId) ?? null, epoch: 'test' - } as unknown as AgentSessionJournal + }) as unknown as AgentSessionJournal return { journal, fence: 1, publish: vi.fn() } } diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 1b03881e471b..f4dfbc9c71f6 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -210,7 +210,7 @@ describe('Claude structured journal translation', () => { messageId: 'msg_01', startUuid: 'block-start-1', finalUuid: 'assistant-final-1', - chunks: ['ST', 'REAMOK_ELEC_64E632'] + chunks: ['ST', 'REAMOK_ELEC_64E632', ' streamed on past the next checkpoint'] }) const streamedIdentity = { provider: 'claude', @@ -229,7 +229,13 @@ describe('Claude structured journal translation', () => { for (const delta of turn.deltas) { translator.handle(delta) } - expect(assistantMessages(state.items)).toEqual([]) + // The first delta made the block's row at once; the rest waits for the window. + expect(assistantMessages(state.items)).toEqual([ + { + identity: streamedIdentity, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ST' }] } + } + ]) const run = scheduled as (() => void) | null run?.() diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 78c63af80752..17f88e052884 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -129,8 +129,8 @@ export async function cancelClaudeStructuredTurn(input: { return { cancelled: false } } // Judge against the published journal, because that is the only turn a client could have been - // shown — but only while it HAS an answer. The journal drains through a serialized async queue, - // so a null read means the row has not landed yet, not that nothing is running; falling back to + // shown — but only while it HAS an answer. A null read can mean the row never landed (a sink not + // yet bound, or refusing under backpressure), not that nothing is running; falling back to // the in-memory turn there keeps Stop from being gated on bookkeeping. No live turn either way // means nothing has published an identity this request can contradict. const ownsRequestedTurn = (): boolean => { diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index d89bd1feb094..cc2875b0ff5b 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -20,6 +20,7 @@ import { identityFor, PROVIDER_SESSION_ID } from './claude-structured-session-test-support' +import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' function controlledSink(): { sink: StructuredAgentSessionEventSink @@ -48,7 +49,7 @@ function persistedTarget( ): StructuredAgentSessionEventTarget { const journal = // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test double implements the journal methods exercised by the deferred sink. - { + withJournalQueueMembers({ appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { persisted.set(agentJournalItemKey(identity), body) return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 } @@ -67,7 +68,7 @@ function persistedTarget( }) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'], itemBody: (itemId: string) => persisted.get(itemId) ?? null, epoch: 'test' - } as unknown as AgentSessionJournal + }) as unknown as AgentSessionJournal return { journal, fence: 1, publish: vi.fn() } } diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts index 5341d53af627..e502b074d20a 100644 --- a/src/main/claude/claude-subagent-group-row.ts +++ b/src/main/claude/claude-subagent-group-row.ts @@ -92,13 +92,12 @@ export function writeClaudeSubagentGroupRow( group: RosterGroup ): void { const agents = [...group.entries.values()].map((tracked) => tracked.entry) - const options = { coalescingKey: `claude-subagents:${group.groupId}`, turnScope: group.turnScope } if (agents.length === 0) { // The row's last child turned out not to be a subagent. An empty roster is // not a roster of nothing, so the row goes rather than reading "Ran 0". if (group.lastSerialized !== null) { group.lastSerialized = null - sink.appendTombstone(group.identity, options) + sink.appendTombstone(group.identity) sink.publish() } return @@ -110,7 +109,7 @@ export function writeClaudeSubagentGroupRow( return } group.lastSerialized = serialized - sink.appendItem(group.identity, body, options) + sink.appendItem(group.identity, body, { turnScope: group.turnScope }) // Publish keeps the sink's own coalescing slot: sharing the row's key makes // each queued publish evict the append it was meant to flush. sink.publish() diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index 34ff506ee037..20587753ece2 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -14,6 +14,7 @@ import { type StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { ClaudeSubagentRoster } from './claude-subagent-roster' +import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' const TURN_1 = 'claude-session:turn-1' @@ -455,13 +456,14 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => { it('lands every revision, not just the one that was already in flight', async () => { const appended: AgentJournalItemBody[] = [] let published = 0 - const journal = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal members this path calls; the helper adds the in-order ones. + const journal = withJournalQueueMembers({ appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { appended.push(body) return { cursor: { epoch: 'e', sequence: appended.length } } }, appendTombstone: async () => ({ epoch: 'e', sequence: 0 }) - } as unknown as AgentSessionJournal + }) as unknown as AgentSessionJournal const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind({ journal, diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index 306fcf276198..33b3fa7b3cb9 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -1,4 +1,3 @@ -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalRowAttribution @@ -118,10 +117,8 @@ export function createCodexStructuredItemStreams( if (!translated.body) { return true } - return appendCodexItemAndPublish(deps.sink, state.identity, translated.body, { - coalescingKey: `checkpoint:${agentJournalItemKey(state.identity)}`, - ...attributionOf(key) - }).accepted + return appendCodexItemAndPublish(deps.sink, state.identity, translated.body, attributionOf(key)) + .accepted } const persist = (key: string, text: string, force: boolean): boolean => { diff --git a/src/main/codex/codex-structured-journal-generic-frames.ts b/src/main/codex/codex-structured-journal-generic-frames.ts index a8b400a6d93d..79d40d7082f3 100644 --- a/src/main/codex/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/codex-structured-journal-generic-frames.ts @@ -142,7 +142,6 @@ export class CodexJournalGenericFrames { } // A summary across evicted turns names no producer and belongs to no turn. const options = { - coalescingKey: `provider-frame-suppressed:codex:${bucket}`, ...(summary.producer ? this.deps.attributionFor(summary.producer.threadId, summary.producer.turnId) : { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 6df0ba015757..9d8c55319096 100644 --- a/src/main/codex/codex-structured-journal-goal-resume.test.ts +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -12,6 +12,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexJournalGoals } from './codex-structured-journal-goals' import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' +import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' @@ -45,7 +46,7 @@ function goalJournal( const writes: string[] = [] const deferred = createDeferredStructuredAgentSessionEventSink(options) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a fake journal exposing only the members the goal translator and deferred sink call. - const journal = { + const journal = withJournalQueueMembers({ get epoch() { return `epoch-${epochNumber}` }, @@ -77,7 +78,7 @@ function goalJournal( visit(item.itemId, item.sequence, item.body) } } - } as unknown as StructuredAgentSessionEventTarget['journal'] + }) as unknown as StructuredAgentSessionEventTarget['journal'] const target = { journal, fence: 1, diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index 344d99e03b4d..b37487eadbfb 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -25,6 +25,7 @@ import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -104,7 +105,8 @@ function deferredTarget( ): StructuredAgentSessionEventTarget { return { fence: 7, - journal: { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal members this path calls; the helper adds the in-order ones. + journal: withJournalQueueMembers({ appendItem: vi.fn(async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { log.push(body) return { cursor: { epoch: 'e', sequence: log.length } } @@ -120,7 +122,7 @@ function deferredTarget( return { epoch: 'e', sequence: log.length } } ) - } as unknown as StructuredAgentSessionEventTarget['journal'], + }) as unknown as StructuredAgentSessionEventTarget['journal'], publish: vi.fn(() => { publishes.push('publish') }) diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index ce6fd220132f..83c917de732f 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -548,11 +548,20 @@ describe('codex journal translation', () => { translator.handle( notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) ) - translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'gone' })) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'begun' })) + translator.handle( + notification('item/agentMessage/delta', { + itemId: 'item-1', + delta: ' and then gone, before the window closed on it' + }) + ) translator.dispose() window.fire() - expect(tap.rows).toEqual([]) + // Only the row the first delta made at once. + expect(tap.rows.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'begun' }] } + ]) }) }) diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 676d9054ed70..5ea2d0263704 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -18,6 +18,7 @@ import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -102,7 +103,8 @@ function deferredTarget( ): StructuredAgentSessionEventTarget { return { fence: 7, - journal: { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal members this path calls; the helper adds the in-order ones. + journal: withJournalQueueMembers({ appendItem: vi.fn(async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { log.push(body) return { cursor: { epoch: 'e', sequence: log.length } } @@ -118,7 +120,7 @@ function deferredTarget( return { epoch: 'e', sequence: log.length } } ) - } as unknown as StructuredAgentSessionEventTarget['journal'], + }) as unknown as StructuredAgentSessionEventTarget['journal'], publish: vi.fn(() => { publishes.push('publish') }) @@ -573,17 +575,17 @@ describe('codex journal translation', () => { translator.handle( notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) ) + const rest = 'llo, and on past the next checkpoint' translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'he' })) - translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'llo' })) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: rest })) window.fire() - // `item/started` had no text to journal; only the coalesced snapshot lands. - expect(tap.rows).toEqual([ - { - key: 'codex:thread-abc:turn-1:0', - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] } - } - ]) + // `item/started` had no text to journal; the first delta makes the row, the window its snapshot. + const reply = (text: string) => ({ + key: 'codex:thread-abc:turn-1:0', + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } + }) + expect(tap.rows).toEqual([reply('he'), reply(`he${rest}`)]) }) it('upserts the streamed text and the completed body onto one row, body last', () => { diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index dcaecb0e3e56..e310d78a5158 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -7,7 +7,12 @@ import type { } from '../../shared/agent-session-journal-types' import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' import { isSubagentGroupBlock, type NativeChatSubagentEntry } from '../../shared/native-chat-types' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink, + type StructuredAgentSessionEventTarget +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' import { CodexSubagentRoster, codexSubagentGroupIdentity, @@ -106,42 +111,24 @@ function deliver( roster.handleItem({ threadId: THREAD, turnId, item }) } -/** - * A sink that coalesces the way the real queue does: by `coalescingKey` ALONE, - * with no op-kind check, and only draining when released. A fake that ignores - * the key cannot see an append being spliced out by its own publish. - */ -function createCoalescingHarness(): { +/** The real event sink, unbound until `drain`, over a journal double that records appends. */ +function createQueuedHarness(): { roster: CodexSubagentRoster appended: Appended[] drain: () => void } { const appended: Appended[] = [] - const queue: { key?: string; run: () => void }[] = [] let clock = 1_000 - const submit = (key: string | undefined, run: () => void): void => { - const at = key === undefined ? -1 : queue.findIndex((queued) => queued.key === key) - if (at >= 0) { - queue.splice(at, 1) - } - queue.push(key === undefined ? { run } : { key, run }) - } - const sink: StructuredAgentSessionEventSink = { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - tryAppendItem: (identity, body, options) => { - submit(options?.coalescingKey, () => appended.push({ identity, body })) - return { accepted: true } - }, - tryPublish: (options) => { - submit(options?.coalescingKey ?? 'publish', () => {}) - return { accepted: true } + const deferred = createDeferredStructuredAgentSessionEventSink() + const journal = withJournalQueueMembers({ + appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + appended.push({ identity, body }) + return { cursor: { epoch: 'e', sequence: appended.length } } } - } + }) const roster = new CodexSubagentRoster({ turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, - sink, + sink: deferred.sink, primaryThreadId: () => THREAD, activeTurn: () => TURN, now: () => (clock += 1) @@ -149,17 +136,19 @@ function createCoalescingHarness(): { return { roster, appended, - drain: () => { - while (queue.length > 0) { - queue.shift()?.run() - } - } + drain: () => + deferred.bind({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the roster's rows reach only appendItem and the in-order members the helper adds. + journal: journal as unknown as StructuredAgentSessionEventTarget['journal'], + fence: 1, + publish: () => {} + }) } } describe('CodexSubagentRoster', () => { it('does not let its own publish evict the still-queued roster append', () => { - const { roster, appended, drain } = createCoalescingHarness() + const { roster, appended, drain } = createQueuedHarness() deliver( roster, @@ -167,8 +156,8 @@ describe('CodexSubagentRoster', () => { ) drain() - // Sharing the append's coalescing key with the publish spliced the append - // out of the queue, and `lastSerialized` then suppressed every retry. + // A publish once shared the append's coalescing key and spliced it out of the queue, and + // `lastSerialized` then suppressed every retry. expect(appended).toHaveLength(1) }) diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index db02ac7f4547..6add0e21d75f 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -335,14 +335,7 @@ export class CodexSubagentRoster { group.lastSerialized = serialized // Deliberately unstamped: a child's frame can trigger this write, but the // row is the PARENT's roster of its children. - // The append coalesces per group so a burst collapses to the latest roster. - // The publish must NOT reuse that key: the queue coalesces by key alone, - // with no op-kind check, so a publish carrying it would splice out the - // still-queued append and the row would never reach the journal. - const options = { - coalescingKey: `codex-subagents:${group.groupId}`, - turnScope: group.turnScope - } + const options = { turnScope: group.turnScope } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(group.identity, body, options) : (this.deps.sink.appendItem(group.identity, body, options), ADMITTED) diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index e0bf1ad62381..146078d830c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -8,6 +8,10 @@ import type { JournalReducerState } from './journal-reducer' import type { JournalAppendResult, JournalItemAppendOptions } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' +const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') + +export type JournalResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } + export class JournalItemAppender { constructor( private readonly deps: { @@ -24,10 +28,45 @@ export class JournalItemAppender { const itemId = agentJournalItemKey(identity) return this.deps .enqueue(journalItemRowBuilder(this.deps.state, identity, body, options)) - .then((row) => ({ - cursor: { epoch: row.epoch, sequence: row.seq }, - itemId, - revision: (row as Extract).revision - })) + .then((row) => appendResult(row, itemId)) + } + + /** `resolve` reads the fold at the write's own place in the queue; null writes nothing. */ + appendResolved( + resolve: () => JournalResolvedItem | null, + options: JournalItemAppendOptions + ): Promise { + let itemId = '' + return this.deps + .enqueue((seq, ts) => { + const resolved = resolve() + if (resolved === null) { + throw NOTHING_RESOLVED + } + itemId = agentJournalItemKey(resolved.identity) + return journalItemRowBuilder( + this.deps.state, + resolved.identity, + resolved.body, + options + )(seq, ts) + }) + .then( + (row) => appendResult(row, itemId), + (error: unknown) => { + if (error === NOTHING_RESOLVED) { + return null + } + throw error + } + ) + } +} + +function appendResult(row: JournalRow, itemId: string): JournalAppendResult { + return { + cursor: { epoch: row.epoch, sequence: row.seq }, + itemId, + revision: (row as Extract).revision } } diff --git a/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts b/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts new file mode 100644 index 000000000000..6f1b3e9dbc3e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts @@ -0,0 +1,112 @@ +// A chat still in the per-chat file an earlier build left, opened the way startup restore opens +// one: its copy into the host's database is owed, and the chat's first write pays it before that +// write lands. It is the one backlog a chat's write queue really holds. + +import { mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import Database from '../../sqlite/sync-database' +import { + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' +import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' +import type { JournalStoredRow } from './journal-row-table' +import type { AgentSessionJournal } from './journal-store' +import { openAgentSessionJournal } from './journal-store-factory' + +const OWED_IMPORT_HISTORY_TEXT = 'history from the earlier build' + +/** Opens `identity`'s chat from a per-chat file of real history rows, its copy still owed. */ +export async function openJournalOwingImport(input: { + stateDirectory: string + identity: AgentSessionJournalIdentity + now?: () => number +}): Promise<{ journal: AgentSessionJournal; history: JournalStoredRow[] }> { + const { stateDirectory, identity } = input + const history = await historyRows(input) + await writePerChatFile(journalDirectoryFor(stateDirectory, identity), identity, history) + const journal = await openAgentSessionJournal({ + identity, + database: openTestJournalHostDatabase(stateDirectory), + ...(input.now ? { now: input.now } : {}), + deferPerSessionImport: true + }) + if (!journal.importPending) { + throw new Error('the chat opened with its copy already made') + } + return { journal, history } +} + +/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ +async function historyRows(input: { + stateDirectory: string + identity: AgentSessionJournalIdentity + now?: () => number +}): Promise { + const scratch = join(input.stateDirectory, `scratch-${input.identity.sessionId}`) + const journal = await openAgentSessionJournal({ + identity: input.identity, + database: openTestJournalHostDatabase(scratch), + ...(input.now ? { now: input.now } : {}) + }) + await journal.appendSubmission({ + clientMessageId: 'client-history', + payloadFingerprint: 'fp-history', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-history', turnId: 'turn-history', ordinal: 1 }, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: OWED_IMPORT_HISTORY_TEXT }] + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.close() + return readTestJournalRows( + openTestJournalHostDatabase(scratch).db, + input.identity.sessionId, + journal.epoch + ) +} + +/** The per-chat file an earlier build left, in its own schema. */ +async function writePerChatFile( + directory: string, + identity: AgentSessionJournalIdentity, + rows: readonly JournalStoredRow[] +): Promise { + const path = legacyJournalDatabaseFile(directory) + await mkdir(dirname(path), { recursive: true }) + const db = new Database(path) + try { + db.pragma('journal_mode = WAL') + db.exec(` +CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, + ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); +CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, + content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) + db.pragma('user_version = 2') + const insert = db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + for (const row of rows) { + insert.run(identity.sessionId, row.epoch, row.seq, row.ts, row.rowJson) + } + db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run( + identity.sessionId, + rows[0]!.epoch, + 1 + ) + } finally { + db.close() + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index c252a57d8a57..964ea42f9318 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -73,14 +73,18 @@ export async function rejectJournalQueuedSubmissions( const queued = journal .submissions() .filter((entry) => isQueuedAgentJournalSubmission(entry) && which(entry)) - for (const entry of queued) { - await journal.resolveDispatch({ - clientMessageId: entry.clientMessageId, - state: 'rejected', - ...rejection, - fence, - recovered: true - }) - } + // Issued together, so the fold shows none of them queued once this call returns: a Stop decides + // whether anything is working from it without awaiting the withdrawal. + await Promise.all( + queued.map((entry) => + journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'rejected', + ...rejection, + fence, + recovered: true + }) + ) + ) return queued.map((entry) => entry.clientMessageId) } diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5b046ab421f2..19baa5ed9182 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -64,7 +64,7 @@ import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' -import type { JournalItemAppender } from './journal-item-appender' +import type { JournalItemAppender, JournalResolvedItem } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' export { AgentSessionJournalError } from './journal-write-guards' @@ -268,6 +268,12 @@ export class AgentSessionJournal { canonicalItemId = (itemId: string): string => resolveJournalItemId(this.state, itemId) + /** Reads the fold with every write issued before this call committed, and none issued after: at + * once unless writes still wait behind an owed import or a running write. */ + readInOrder(read: () => T): Promise { + return this.queue.readInOrder(read) + } + readSince(cursor: AgentJournalCursor, limit?: number): JournalReadSince { return readJournalSince( { @@ -297,6 +303,14 @@ export class AgentSessionJournal { return this.itemAppender.append(identity, body, options) } + /** An upsert whose row is chosen from the fold at its own turn in the queue; null writes nothing. */ + appendResolvedItem( + resolve: () => JournalResolvedItem | null, + options: JournalItemAppendOptions + ): Promise { + return this.itemAppender.appendResolved(resolve, options) + } + appendTombstone( identity: AgentJournalItemIdentity, options: JournalTombstoneInput diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts new file mode 100644 index 000000000000..3bee8607811b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts @@ -0,0 +1,246 @@ +// A write has landed in the fold when its call returns, except during an owed import, when it lands +// in queue order. A write issued from inside a running write joins the line behind it, never nested. +// A read in the queue always settles: behind a write that failed, and refused once closed. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { openJournalOwingImport } from './journal-owed-import-test-support' +import type { AgentSessionJournal } from './journal-store' +import { openAgentSessionJournal } from './journal-store-factory' +import { JournalWriteQueue } from './journal-write-queue' + +const IDENTITY = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} as const + +const reply = (text: string): AgentJournalMessageItem => ({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text }] +}) + +const item = (ordinal: number): AgentJournalItemIdentity => ({ + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal +}) + +const OPTIONS = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + +let root = '' +const opened: AgentSessionJournal[] = [] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-write-queue-')) +}) + +afterEach(async () => { + await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +async function idleJournal(): Promise { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + database: openTestJournalHostDatabase(root) + }) + opened.push(journal) + return journal +} + +async function journalOwingImport(): Promise<{ journal: AgentSessionJournal; history: number }> { + const { journal, history } = await openJournalOwingImport({ + stateDirectory: root, + identity: IDENTITY + }) + opened.push(journal) + return { journal, history: history.length } +} + +function sequenceOf(journal: AgentSessionJournal, ordinal: number): number | undefined { + const key = agentJournalItemKey(item(ordinal)) + let found: number | undefined + journal.visitItems((itemId, sequence) => { + if (itemId === key) { + found = sequence + } + }) + return found +} + +describe('when a journal write lands', () => { + it('is in the fold when its call returns, on an idle queue', async () => { + const journal = await idleJournal() + const before = journal.cursor().sequence + + const first = journal.appendItem(item(1), reply('one'), OPTIONS) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('one')) + const second = journal.appendItem(item(2), reply('two'), OPTIONS) + expect(journal.cursor().sequence).toBe(before + 2) + expect(sequenceOf(journal, 2)).toBeGreaterThan(sequenceOf(journal, 1)!) + + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + }) + + it('lands in queue order, behind the history it copies, while an import is owed', async () => { + const { journal, history } = await journalOwingImport() + + const first = journal.appendItem(item(1), reply('one'), OPTIONS) + const second = journal.appendItem(item(2), reply('two'), OPTIONS) + // Neither has landed: both wait behind the copy the first write pays. + expect(journal.itemBody(agentJournalItemKey(item(1)))).toBeNull() + expect(journal.importPending).toBe(true) + + await second + await first + expect(journal.importPending).toBe(false) + expect(sequenceOf(journal, 1)).toBe(history + 1) + expect(sequenceOf(journal, 2)).toBe(history + 2) + // The copy is paid: the next write lands at its call again. + journal.appendItem(item(3), reply('three'), OPTIONS).catch(() => undefined) + expect(sequenceOf(journal, 3)).toBe(history + 3) + }) + + it('joins the line behind a write it was issued from inside, never nested in it', async () => { + const journal = await idleJournal() + let inner: Promise | null = null + let innerLandedDuringOuter = false + + // Issued from inside the outer write, before that write has committed its row. + const outer = journal.appendResolvedItem(() => { + inner = journal.appendItem(item(2), reply('inner'), OPTIONS) + innerLandedDuringOuter = journal.itemBody(agentJournalItemKey(item(2))) !== null + return { identity: item(1), body: reply('outer') } + }, OPTIONS) + + expect(innerLandedDuringOuter).toBe(false) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('outer')) + expect(journal.itemBody(agentJournalItemKey(item(2)))).toBeNull() + await outer + await inner + expect(sequenceOf(journal, 2)).toBe(sequenceOf(journal, 1)! + 1) + }) +}) + +describe('the journal write queue', () => { + it('runs a write before it returns when nothing is owed or running', () => { + const queue = new JournalWriteQueue('session-1') + const ran: string[] = [] + void queue.serialize(async () => { + ran.push('write') + }) + expect(ran).toEqual(['write']) + }) + + it('answers a write that throws with a rejection, never a throw', async () => { + const queue = new JournalWriteQueue('session-1') + const failed = queue.serialize(() => { + throw new Error('disk I/O error') + }) + await expect(failed).rejects.toThrow('disk I/O error') + const ran: string[] = [] + void queue.serialize(async () => { + ran.push('next') + }) + expect(ran).toEqual(['next']) + }) + + it('runs a write issued from inside a running one after it, in issue order', async () => { + const queue = new JournalWriteQueue('session-1') + const ran: string[] = [] + let nested: Promise | null = null + const outer = queue.serialize(async () => { + nested = queue.serialize(async () => { + ran.push('nested') + }) + ran.push('outer') + }) + const after = queue.serialize(async () => { + ran.push('after') + }) + expect(ran).toEqual(['outer']) + await Promise.all([outer, nested, after]) + expect(ran).toEqual(['outer', 'nested', 'after']) + }) + + it('runs every write admitted while work is owed after it, in order', async () => { + const queue = new JournalWriteQueue('session-1') + const ran: string[] = [] + const owed = Promise.withResolvers() + queue.owe(async () => { + await owed.promise + ran.push('owed') + }) + const first = queue.serialize(async () => { + ran.push('first') + }) + const read = queue.readInOrder(() => ran.push('read')) + owed.resolve() + const second = queue.serialize(async () => { + ran.push('second') + }) + await Promise.all([first, read, second]) + expect(ran).toEqual(['owed', 'first', 'read', 'second']) + expect(queue.owing).toBe(false) + }) +}) + +describe('a read in the journal write queue', () => { + it('runs at once when no write waits', () => { + const queue = new JournalWriteQueue('session-1') + const ran: string[] = [] + void queue.readInOrder(() => ran.push('read')) + expect(ran).toEqual(['read']) + }) + + it('does not pay owed work, and runs behind a write that pays it', async () => { + const queue = new JournalWriteQueue('session-1') + const ran: string[] = [] + queue.owe(async () => { + ran.push('owed') + }) + void queue.readInOrder(() => ran.push('read before')) + expect(ran).toEqual(['read before']) + const write = queue.serialize(async () => { + ran.push('write') + }) + const read = queue.readInOrder(() => ran.push('read after')) + await Promise.all([write, read]) + expect(ran).toEqual(['read before', 'owed', 'write', 'read after']) + }) + + it('runs behind a write that failed', async () => { + const queue = new JournalWriteQueue('session-1') + queue.owe(async () => undefined) + const failed = queue.serialize(async () => { + throw new Error('disk I/O error') + }) + const read = queue.readInOrder(() => 'read') + await expect(failed).rejects.toThrow('disk I/O error') + await expect(read).resolves.toBe('read') + }) + + it('is refused at once after close, not queued', async () => { + const queue = new JournalWriteQueue('session-1') + queue.markClosed() + await expect(queue.readInOrder(() => 'read')).rejects.toMatchObject({ code: 'journal_closed' }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index 13fd9e4b96c2..b91e06870c7a 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -6,9 +6,22 @@ import { AgentSessionJournalError } from './journal-write-guards' -/** Admission is checked at ENQUEUE and is permanent. */ +/** + * A write has landed in the fold when its call returns, except during an owed import, when it + * lands in queue order. + * + * A write finds the queue idle unless work is owed, a write is running, or writes wait in line; + * then it runs before `serialize` returns. Every write body is synchronous (a transaction refuses + * to await), so it has committed by then. Otherwise it joins the line: behind the owed import it + * pays first, or behind the running write it was issued from, never nested inside it. + * Admission is checked at ENQUEUE and is permanent. + */ export class JournalWriteQueue { + /** Settles once every write admitted so far has, whatever its outcome. */ private writes: Promise = Promise.resolve() + /** Writes that joined the line and have not settled. */ + private waiting = 0 + private running = false private closed = false /** Runs before the next write, and stays owed until it succeeds. */ private owed: (() => Promise) | null = null @@ -21,14 +34,33 @@ export class JournalWriteQueue { serialize(run: () => Promise): Promise { if (this.closed) { - return Promise.reject( - new AgentSessionJournalError( - 'journal_closed', - `agent-session journal for ${this.sessionId} is closed` - ) - ) + return Promise.reject(this.closedError()) } - return this.serializePastGate(run) + return this.owed !== null || this.lineBusy + ? this.join(run, this.owed !== null) + : this.runNow(run) + } + + /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any + * admitted after: at once when none waits. Owed work is not paid for a read. A closed queue + * refuses it, as it refuses a write: its fold may be replaced. */ + readInOrder(read: () => T): Promise { + if (this.closed) { + return Promise.reject(this.closedError()) + } + const step = async (): Promise => read() + return this.lineBusy ? this.join(step, false) : this.runNow(step) + } + + private get lineBusy(): boolean { + return this.running || this.waiting > 0 + } + + private closedError(): AgentSessionJournalError { + return new AgentSessionJournalError( + 'journal_closed', + `agent-session journal for ${this.sessionId} is closed` + ) } owe(work: () => Promise): void { @@ -52,10 +84,33 @@ export class JournalWriteQueue { return this.writes.then(() => undefined) } - private serializePastGate(run: () => Promise): Promise { - // Only a write admitted while work is owed takes the extra step, so no other write's timing moves. - const started = this.owed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run) + /** Runs before returning; a write issued from inside it joins the line behind it. */ + private runNow(run: () => Promise): Promise { + let release: (settled: Promise) => void = () => undefined + this.writes = new Promise((resolve) => { + release = resolve + }) + this.running = true + let result: Promise + try { + result = Promise.resolve(run()) + } catch (error) { + result = Promise.reject(error) + } finally { + this.running = false + } + release(result.catch(() => undefined)) + return result + } + + private join(run: () => Promise, paysOwed: boolean): Promise { + const started = paysOwed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run) this.writes = started.catch(() => undefined) + this.waiting++ + const settle = (): void => { + this.waiting-- + } + started.then(settle, settle) return started } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts index fcfafac9ae96..f6d710e2d53a 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts @@ -40,21 +40,29 @@ function coalescer(clock: ReturnType, windowMs?: number) { } describe('agent-session delta coalescer', () => { - it('folds a burst into one emit carrying the full text, on one shared window', () => { + // The first delta makes the row, so a write issued after the text began lands below it. + it("emits a stream's first delta at once, then folds a burst into one emit on one shared window", () => { const clock = manualClock() const { instance, emitted } = coalescer(clock) instance.append('item-1', 'he') + expect(emitted).toEqual([['item-1', 'he']]) + expect(clock.windows()).toEqual([]) instance.append('item-1', 'llo') + instance.append('item-1', ' there') instance.append('item-2', 'world') - expect(emitted).toEqual([]) + expect(emitted).toEqual([ + ['item-1', 'he'], + ['item-2', 'world'] + ]) expect(clock.windows()).toEqual([AGENT_SESSION_DELTA_COALESCE_MS]) clock.fire() expect(emitted).toEqual([ - ['item-1', 'hello'], - ['item-2', 'world'] + ['item-1', 'he'], + ['item-2', 'world'], + ['item-1', 'hello there'] ]) }) @@ -82,7 +90,10 @@ describe('agent-session delta coalescer', () => { } clock.fire() - expect(emitted).toEqual([['item-1', 'x'.repeat(10_000)]]) + expect(emitted).toEqual([ + ['item-1', 'x'], + ['item-1', 'x'.repeat(10_000)] + ]) }) it('does not re-emit a stream with no new text', () => { @@ -151,7 +162,10 @@ describe('agent-session delta coalescer', () => { expect(instance.snapshot('first')?.text).toBe('preserve me') reject = false expect(instance.append('second', 'new stream')).toBe(true) - expect(emitted).toEqual([['first', 'preserve me']]) + expect(emitted).toEqual([ + ['first', 'preserve me'], + ['second', 'new stream'] + ]) }) it('drops a forgotten stream without emitting it, because its final body already landed', () => { @@ -160,21 +174,28 @@ describe('agent-session delta coalescer', () => { instance.append('item-1', 'stale') instance.append('item-2', 'kept') + instance.append('item-1', ' and more') + instance.append('item-2', ' too') instance.forget('item-1') clock.fire() - expect(emitted).toEqual([['item-2', 'kept']]) + expect(emitted).toEqual([ + ['item-1', 'stale'], + ['item-2', 'kept'], + ['item-2', 'kept too'] + ]) }) it('emits nothing after dispose, and leaves no timer behind', () => { const clock = manualClock() const { instance, emitted } = coalescer(clock) - instance.append('item-1', 'gone') + instance.append('item-1', 'begun') + instance.append('item-1', ' and gone') instance.dispose() clock.fire() - expect(emitted).toEqual([]) + expect(emitted).toEqual([['item-1', 'begun']]) expect(clock.pendingCount()).toBe(0) }) @@ -183,6 +204,7 @@ describe('agent-session delta coalescer', () => { const { instance } = coalescer(clock, 5) instance.append('item-1', 'x') + instance.append('item-1', 'y') expect(clock.windows()).toEqual([5]) }) @@ -203,6 +225,7 @@ describe('agent-session delta coalescer', () => { clock.fire() expect(emitted).toEqual([ + { text: 'éé', observedBytes: 4, truncated: false }, { text: 'ééé\n[Orca: streamed output truncated]', observedBytes: 48, diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index 1d5608150eff..ada37bcd8246 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -9,7 +9,10 @@ // // The window applies to text only. Lifecycle — an item completing, a turn // ending, an approval arriving — bypasses it by flushing first, so nothing can -// be journaled ahead of the text that preceded it. +// be journaled ahead of the text that preceded it. A stream's first delta is +// emitted at once, so its row takes its place in the journal when the text +// begins and any write issued after lands below it; the window only delays +// later snapshots of that row. /** Long enough to fold a burst of tokens into one row, short enough that the * text still reads as streaming. */ @@ -81,6 +84,8 @@ export function createAgentSessionDeltaCoalescer( observedBytes: number truncated: boolean dirty: boolean + /** An emit has succeeded, so the row exists and later text only revises it. */ + emitted: boolean } >() let totalRetainedBytes = 0 @@ -102,6 +107,7 @@ export function createAgentSessionDeltaCoalescer( return false } stream.dirty = false + stream.emitted = true return true } @@ -157,7 +163,8 @@ export function createAgentSessionDeltaCoalescer( retainedBytes: 0, observedBytes: 0, truncated: false, - dirty: false + dirty: false, + emitted: false } if (!deps.isProtected?.(key)) { evictable.add(key) @@ -184,6 +191,10 @@ export function createAgentSessionDeltaCoalescer( stream.dirty = true } streams.set(key, stream) + // The first text makes the row; refused under backpressure, it waits for the window. + if (!stream.emitted && stream.dirty && stream.retainedBytes > 0 && flushKey(key)) { + return true + } // One timer for every stream: a shared deadline bounds latency the same // way and costs one wakeup per window instead of one per stream. scheduleFlush() diff --git a/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts b/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts index 0067aea8ed5d..e3eae8082ad5 100644 --- a/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts @@ -84,7 +84,8 @@ describe('provider turn activity routing', () => { delta: 'Tracing the activity pipeline' }) ) - expect(state.rows).toHaveLength(lifecycleRows) + // The reasoning's own row, made at its first delta; no protocol row beside it. + expect(state.rows.slice(lifecycleRows).map((row) => row.kind)).toEqual(['message']) expect(state.activities.at(-1)?.text).toBe('Tracing the activity pipeline') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index bda7cc9330f2..b3d9acdd68dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -36,10 +36,10 @@ let host: StructuredAgentSessionHost let acquire: Mock let generation = 0 -/** Everything a live subscriber was sent after it opened. */ -function liveReader() { +/** Everything a live subscriber was sent after its opening snapshot. */ +async function liveReader() { const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => events.push(event) }) + await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => events.push(event) }) const opened = events.length const received = () => { const items: AgentJournalRenderItem[] = [] @@ -156,7 +156,7 @@ afterEach(async () => { describe('an open chat receives every row its journal commits', () => { it('shows a failed start whose lease could not be handed back', async () => { const held = await send('hello') - const pane = liveReader() + const pane = await liveReader() // The exit settles the journal, then fails to release the lease: nothing moves the fence. vi.spyOn(store, 'transitionHandoff').mockRejectedValueOnce(new Error('record store busy')) @@ -175,7 +175,7 @@ describe('an open chat receives every row its journal commits', () => { }) it('shows a revision the provider queued with no publish behind it', async () => { - const pane = liveReader() + const pane = await liveReader() const identity = { provider: 'orca' as const, clientMessageId: 'context-usage' } const body = { kind: 'status' as const, text: 'context usage answered after the turn' } @@ -192,7 +192,7 @@ describe('an open chat receives every row its journal commits', () => { }) it('shows a row appended straight to the journal', async () => { - const pane = liveReader() + const pane = await liveReader() const journal = host['sessions'].get(SESSION)?.journal if (!journal) { throw new Error('the attached chat has no journal') @@ -213,7 +213,7 @@ describe('an open chat receives every row its journal commits', () => { describe('an open chat receives each row once', () => { it('when the provider frame that wrote it also publishes', async () => { - const pane = liveReader() + const pane = await liveReader() const sink = providerSink() const journal = host['sessions'].get(SESSION)?.journal if (!journal) { @@ -235,7 +235,7 @@ describe('an open chat receives each row once', () => { }) it('when a writer publishes the row it appended', async () => { - const pane = liveReader() + const pane = await liveReader() const journal = host['sessions'].get(SESSION)?.journal if (!journal) { throw new Error('the attached chat has no journal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index 1ba4f13e4ea8..ebdcc0b2397d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -94,7 +94,7 @@ function settled(): Promise { return new Promise((resolve) => setTimeout(resolve, 20)) } -it('reads working at every published frame from the send through the echo that opens its turn', async () => { +async function expectWorkingThroughEcho(backlog: boolean): Promise { const submissions = new Map() const turns = new Map() const working: boolean[] = [] @@ -136,6 +136,20 @@ it('reads working at every published frame from the send through the echo that o }) await settled() const connection = claude.current.connections[0]! + if (backlog) { + // The previous cycle's result in the same read, its write issued just ahead of the echo's. + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'success', + uuid: 'result-0', + session_id: PROVIDER_SESSION_ID, + duration_ms: 1, + duration_api_ms: 1, + num_turns: 1, + is_error: false, + result: '' + }) + } // Claude echoes the written message back, which is what opens its turn. connection.handlers.onMessage?.({ ...connection.sent.at(-1)!, uuid: 'echo-uuid' }) await settled() @@ -145,4 +159,10 @@ it('reads working at every published frame from the send through the echo that o ]) expect([...turns.values()]).toEqual(['running']) expect(working).not.toContain(false) -}) +} + +it('reads working at every published frame from the send through the echo that opens its turn', () => + expectWorkingThroughEcho(false)) + +it('keeps the settlement behind the turn its echo opens when the previous result arrives in the same read', () => + expectWorkingThroughEcho(true)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index cb83dba870a1..bf94465b72f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -153,7 +153,6 @@ export type StructuredAgentSessionCommandHandoverContext = { /** Who a failure the handover meets names, as the start's own row does. */ failureTextContext?: AgentSessionFailureWordsContext record: () => AgentSessionRecord | null - flushStreamedEvents: () => Promise now: () => number } @@ -164,8 +163,7 @@ export async function handOverStructuredAgentSessionCommand( body: AgentJournalMessageItem ): Promise { const { clientMessageId } = submission - // Provider frames already received decide whether a turn is running. - await ctx.flushStreamedEvents() + // Provider frames already received decide whether a turn is running: each landed at its call. const blocked = commandBlocked(ctx, body) if (blocked) { await ctx.journal.resolveDispatch({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 2eeae4787ce5..331f968818fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -283,34 +283,22 @@ describe('a Stop that names no turn', () => { it('interrupts a turn whose accepted send is in the journal before its row lands', async () => { await acceptedWithTurnRowUnlanded() - const drain = host.flushStreamedEvents - vi.spyOn(host, 'flushStreamedEvents').mockImplementation((sessionId) => { - events!.appendItem( - { provider: 'legacy', agent: 'codex', sessionId, recordId: 'turn-lifecycle:turn-2' }, - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-2', state: 'running' } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - return drain(sessionId) - }) + // Emitted as the Stop arrives: the Stop's read takes its place behind it in the journal. + events!.appendItem( + { provider: 'legacy', agent: 'codex', sessionId: SESSION, recordId: 'turn-lifecycle:turn-2' }, + { + kind: 'status', + text: 'Agent is working…', + turnLifecycle: { turnId: 'turn-2', state: 'running' } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(cancelTurn).toHaveBeenCalledOnce() expect(await statusRows()).toContain('Cancellation requested.') }) - it('still interrupts when draining the streamed rows fails', async () => { - await acceptedWithTurnRowUnlanded() - vi.spyOn(host, 'flushStreamedEvents').mockRejectedValueOnce(new Error('sink barrier failed')) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledOnce() - expect(await statusRows()).toEqual(['Cancellation requested.']) - }) - it('is a quiet no-op with nothing in flight', async () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(cancelTurn).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 373e2ae3c36e..cbe4b6794ed3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -57,7 +57,6 @@ export type StructuredAgentSessionDeliveryLoopDeps = { failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void record: (sessionId: string) => AgentSessionRecord | null - flushStreamedEvents: (sessionId: string) => Promise now: () => number } @@ -218,7 +217,6 @@ export class StructuredAgentSessionDeliveryLoop { providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, failureTextContext: this.deps.failureTextContext(sessionId), record: () => this.deps.record(sessionId), - flushStreamedEvents: () => this.deps.flushStreamedEvents(sessionId), now: this.deps.now }, next diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 2d0766a4bce8..ffe328504722 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -15,23 +15,35 @@ export type StructuredAgentSessionSinkOperation = { /** Lifecycle rows use their own bounded reservation budget. */ lifecycleBytes?: number lifecycle?: boolean - coalescingKey?: string - /** The queued operation with this key wins, as the journal keeps a settlement's first batch. */ - keepsFirst?: boolean + /** Marks a publication, which writes no row: it runs at handover, or at its place in the + * journal's queue while writes wait there, and one still waiting with the same key is replaced + * by the next. Journal writes never coalesce: + * replacing one would move it behind whatever was issued after it. */ + publicationKey?: string + /** Called at handover, so a journal write it issues takes its place in the chat's one write queue + * in the same tick it was submitted. */ run: (target: StructuredAgentSessionEventTarget) => Promise | void } +type Admitted = StructuredAgentSessionSinkOperation & { superseded?: boolean } + export type StructuredAgentSessionDrainWaiter = { through: number resolve: (result: StructuredAgentSessionSinkBarrier) => void } +/** + * Admission and backpressure for one provider stream's writes. It holds nothing once bound: each + * operation is handed to the journal as it is submitted, so a streamed row and a host write are + * ordered by when they were issued, in the journal's one queue. Only while no journal is bound do + * operations wait here, in arrival order, and bind hands them over before it returns. Pressure + * counts what was admitted and has not yet settled, wherever it waits. + */ export class StructuredAgentSessionSinkQueue { private readingControl: StructuredAgentSessionReadingControl | undefined private target: StructuredAgentSessionEventTarget | null = null private closed = false private failure: { error: unknown } | null = null - private running = false private queuedBytes = 0 private queuedOperations = 0 private lifecycleQueuedBytes = 0 @@ -39,7 +51,10 @@ export class StructuredAgentSessionSinkQueue { private backpressured = false private acceptedSequence = 0 private settledSequence = 0 - private readonly queue: StructuredAgentSessionSinkOperation[] = [] + /** Settles once every operation handed over so far has, in handover order. */ + private handedOverSettled: Promise = Promise.resolve() + private readonly buffered: Admitted[] = [] + private readonly waitingPublications = new Map() private readonly waiters: StructuredAgentSessionDrainWaiter[] = [] constructor( @@ -80,9 +95,12 @@ export class StructuredAgentSessionSinkQueue { } bind(target: StructuredAgentSessionEventTarget): void { - if (!this.closed) { - this.target = target - this.pump() + if (this.closed) { + return + } + this.target = target + for (const operation of this.buffered.splice(0)) { + this.handOver(operation, target) } } @@ -90,24 +108,17 @@ export class StructuredAgentSessionSinkQueue { this.target = null } + /** Operations already handed over are the journal's and still land; only buffered ones drop. */ close(): void { this.closed = true - this.queue.length = 0 - this.queuedBytes = 0 - this.queuedOperations = 0 - this.lifecycleQueuedBytes = 0 - this.lifecycleQueuedOperations = 0 - this.settledSequence = this.acceptedSequence + this.dropBuffered() this.updateBackpressure() - this.settleWaiters() } barrier = (): Promise => { const through = this.acceptedSequence if (this.settledSequence >= through) { - return Promise.resolve( - this.failure === null ? { ok: true } : { ok: false, error: this.failure.error } - ) + return Promise.resolve(this.barrierResult()) } return new Promise((resolve) => this.waiters.push({ through, resolve })) } @@ -122,21 +133,21 @@ export class StructuredAgentSessionSinkQueue { if (this.failure !== null) { return { accepted: false, reason: 'failed' } } - const key = options.coalescingKey ?? operation.coalescingKey - const replaceAt = key ? this.queue.findIndex((queued) => queued.coalescingKey === key) : -1 - const replaced = replaceAt >= 0 ? this.queue[replaceAt] : undefined - if (replaced && operation.keepsFirst) { - return { accepted: true } - } - const sequence = ++this.acceptedSequence + const key = operation.publicationKey + const replaceAt = + key === undefined ? -1 : this.buffered.findIndex((queued) => queued.publicationKey === key) + const replaced = + replaceAt >= 0 + ? this.buffered[replaceAt] + : key === undefined + ? undefined + : this.waitingPublications.get(key) const lifecycle = operation.lifecycle ?? options.lifecycle === true const lifecycleBytes = lifecycle ? (operation.lifecycleBytes ?? operation.bytes) : 0 const nextBytes = this.queuedBytes - (replaced?.bytes ?? 0) + operation.bytes const nextOperations = this.queuedOperations + (replaced ? 0 : 1) const nextLifecycleBytes = - this.lifecycleQueuedBytes - - (replaced?.lifecycle ? (replaced.lifecycleBytes ?? replaced.bytes) : 0) + - lifecycleBytes + this.lifecycleQueuedBytes - (replaced ? lifecycleCost(replaced) : 0) + lifecycleBytes const nextLifecycleOperations = this.lifecycleQueuedOperations - (replaced?.lifecycle ? 1 : 0) + (lifecycle ? 1 : 0) const exceedsOrdinary = @@ -148,30 +159,95 @@ export class StructuredAgentSessionSinkQueue { (nextLifecycleBytes > this.deps.watermarks.maxLifecycleQueuedBytes || nextLifecycleOperations > this.deps.watermarks.maxLifecycleQueuedOperations) if (exceedsOrdinary || exceedsLifecycle) { - this.acceptedSequence -= 1 this.setBackpressure(true) return { accepted: false, reason: 'backpressure' } } - const accepted = { + const accepted: Admitted = { ...operation, - sequence, + sequence: ++this.acceptedSequence, lifecycle, - lifecycleBytes, - ...(key ? { coalescingKey: key } : {}) + lifecycleBytes } - if (replaced) { - this.queue.splice(replaceAt, 1) + if (replaceAt >= 0) { + this.buffered.splice(replaceAt, 1) + } else if (replaced) { + // Its turn still comes, and settles as nothing: the publication after it covers it. + replaced.superseded = true } - this.queue.push(accepted) this.queuedBytes = nextBytes this.queuedOperations = nextOperations this.lifecycleQueuedBytes = nextLifecycleBytes this.lifecycleQueuedOperations = nextLifecycleOperations + if (this.target) { + this.handOver(accepted, this.target) + } else { + this.buffered.push(accepted) + } this.updateBackpressure() - this.pump() return { accepted: true } } + private handOver(operation: Admitted, bound: StructuredAgentSessionEventTarget): void { + const earlier = this.handedOverSettled + const key = operation.publicationKey + let outcome: Promise + if (key === undefined) { + outcome = runNow(() => operation.run(bound)) + } else { + this.waitingPublications.set(key, operation) + // At handover, unless writes still wait behind an owed import; then at its place in line, so + // it never announces ahead of the writes issued before it. + outcome = runNow(() => + bound.journal.readInOrder(() => { + if (this.waitingPublications.get(key) === operation) { + this.waitingPublications.delete(key) + } + // A publication writes no row, so a closed or failed sink has nothing left to announce. + return operation.superseded || this.closed || this.failure !== null + ? undefined + : operation.run(bound) + }) + ) + } + // Handled at once: a write can fail while an earlier one is still landing. + const landed = outcome.then(() => undefined, this.fail) + this.handedOverSettled = Promise.all([earlier, landed]).then(() => this.settle(operation)) + } + + private settle(operation: Admitted): void { + if (!operation.superseded) { + this.release(operation) + } + this.settledSequence = Math.max(this.settledSequence, operation.sequence) + this.updateBackpressure() + this.settleWaiters() + } + + private release(operation: Admitted): void { + this.queuedBytes = Math.max(0, this.queuedBytes - operation.bytes) + this.queuedOperations = Math.max(0, this.queuedOperations - 1) + if (operation.lifecycle) { + this.lifecycleQueuedBytes = Math.max(0, this.lifecycleQueuedBytes - lifecycleCost(operation)) + this.lifecycleQueuedOperations = Math.max(0, this.lifecycleQueuedOperations - 1) + } + } + + /** Buffered operations never reached a journal. They count as settled once everything handed + * over before them has, so a barrier never resolves ahead of a write still landing. */ + private dropBuffered(): void { + const dropped = this.buffered.splice(0) + for (const operation of dropped) { + this.release(operation) + } + const last = dropped.at(-1) + if (last) { + this.handedOverSettled = this.handedOverSettled.then(() => { + this.settledSequence = Math.max(this.settledSequence, last.sequence) + this.settleWaiters() + }) + } + } + private setBackpressure(next: boolean): void { if (next === this.backpressured) { return @@ -197,60 +273,40 @@ export class StructuredAgentSessionSinkQueue { this.setBackpressure(next) } + private barrierResult(): StructuredAgentSessionSinkBarrier { + return this.failure === null ? { ok: true } : { ok: false, error: this.failure.error } + } + private settleWaiters(): void { for (let index = this.waiters.length - 1; index >= 0; index -= 1) { const waiter = this.waiters[index] if (waiter && waiter.through <= this.settledSequence) { this.waiters.splice(index, 1) - waiter.resolve( - this.failure === null ? { ok: true } : { ok: false, error: this.failure.error } - ) + waiter.resolve(this.barrierResult()) } } } + /** Writes already handed over still land; nothing submitted after this is admitted. */ private fail = (error: unknown): void => { if (this.failure === null) { this.failure = { error } this.deps.onError?.(error) } - this.queue.length = 0 - this.queuedBytes = 0 - this.queuedOperations = 0 - this.lifecycleQueuedBytes = 0 - this.lifecycleQueuedOperations = 0 - this.settledSequence = this.acceptedSequence + this.dropBuffered() this.updateBackpressure() - this.settleWaiters() } +} - private pump(): void { - if (this.running || !this.target || this.closed || this.failure !== null) { - return - } - const operation = this.queue.shift() - if (!operation) { - return - } - this.running = true - const bound = this.target - void Promise.resolve(operation.run(bound)) - .catch(this.fail) - .finally(() => { - this.running = false - this.queuedBytes = Math.max(0, this.queuedBytes - operation.bytes) - this.queuedOperations = Math.max(0, this.queuedOperations - 1) - if (operation.lifecycle) { - this.lifecycleQueuedBytes = Math.max( - 0, - this.lifecycleQueuedBytes - (operation.lifecycleBytes ?? operation.bytes) - ) - this.lifecycleQueuedOperations = Math.max(0, this.lifecycleQueuedOperations - 1) - } - this.settledSequence = Math.max(this.settledSequence, operation.sequence) - this.updateBackpressure() - this.settleWaiters() - this.pump() - }) +function lifecycleCost(operation: StructuredAgentSessionSinkOperation): number { + return operation.lifecycle ? (operation.lifecycleBytes ?? operation.bytes) : 0 +} + +/** A synchronous throw settles as a rejection, like any other failed write. */ +function runNow(run: () => Promise | void): Promise { + try { + return Promise.resolve(run()) + } catch (error) { + return Promise.reject(error) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 46dc8fbf7e44..a08025976808 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -1,5 +1,9 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, AgentJournalItemIdentity @@ -15,6 +19,10 @@ import { type StructuredAgentSessionEventTarget } from './structured-agent-session-event-sink' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' +import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' const BODY: AgentJournalItemBody = { kind: 'message', @@ -22,6 +30,14 @@ const BODY: AgentJournalItemBody = { blocks: [{ type: 'text', text: 'hi' }] } +const JOURNAL_IDENTITY = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} as const + function identity(ordinal: number): AgentJournalItemIdentity { return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } } @@ -78,7 +94,20 @@ function target( log.push({ call: 'appendLifecycleBatch', fence, settlementId: input.settlementId }) return { epoch: 'e', sequence: 0 } }), - latestItemMatching: vi.fn(() => null) + latestItemMatching: vi.fn(() => null), + readInOrder: vi.fn(async (read: () => T) => read()), + // The journal resolves at the write's place in its queue; this double has no queue to wait on. + appendResolvedItem: vi.fn( + async ( + resolve: () => { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } | null, + options: JournalItemAppendOptions + ) => { + const resolved = resolve() + return resolved === null + ? null + : journal.appendItem(resolved.identity, resolved.body, options) + } + ) } as unknown as AgentSessionJournal return { journal, @@ -88,6 +117,41 @@ function target( } } +/** A real journal whose copy from an earlier build's per-chat file is still owed: its first write + * pays it, and every write issued meanwhile waits in its queue. The one real backlog a chat has. */ +async function owingTarget(fence: number) { + const root = await mkdtemp(join(tmpdir(), 'orca-event-sink-owed-')) + const { journal, history } = await openJournalOwingImport({ + stateDirectory: root, + identity: JOURNAL_IDENTITY + }) + const published: (AgentSessionTurnActivity | null | undefined)[] = [] + const bound: StructuredAgentSessionEventTarget = { + journal, + fence, + publish: (activity) => published.push(activity) + } + /** The row each of these ordinals created, in journal order. */ + const landed = (): number[] => + journal.snapshot().items.flatMap((item) => { + const ordinal = [0, 1, 2, 3, 4].find( + (index) => item.itemId === agentJournalItemKey(identity(index)) + ) + return ordinal === undefined ? [] : [ordinal] + }) + return { + bound, + journal, + history: history.length, + published, + landed, + dispose: async () => { + await journal.close() + await rm(root, { recursive: true, force: true }) + } + } +} + describe('deferred structured agent-session event sink', () => { it('buffers writes made before the journal exists and drains them in arrival order', async () => { const log: Recorded[] = [] @@ -177,6 +241,27 @@ describe('deferred structured agent-session event sink', () => { expect(log).toEqual([]) }) + it('lands writes already handed to the journal when closed; only never-bound ones drop', async () => { + const owed = await owingTarget(3) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind(owed.bound) + + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + deferred.sink.appendItem(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + // Handed over, waiting behind the copy: none has landed when the sink closes. + expect(owed.landed()).toEqual([]) + deferred.close() + expect( + deferred.sink.tryAppendItem?.(identity(3), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: false, reason: 'closed' }) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + + expect(owed.landed()).toEqual([0, 1, 2]) + expect(owed.journal.cursor().sequence).toBe(owed.history + 3) + await owed.dispose() + }) + it('reports one refused append, fails the barrier, and stops later writes', async () => { const log: Recorded[] = [] const errors: unknown[] = [] @@ -188,8 +273,11 @@ describe('deferred structured agent-session event sink', () => { deferred.bind(target(4, log, 0)) deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.appendTombstone(identity(1)) const barrier = await deferred.drained() + expect(deferred.sink.tryAppendTombstone?.(identity(1))).toEqual({ + accepted: false, + reason: 'failed' + }) expect(errors).toHaveLength(1) expect((errors[0] as Error).message).toBe('refused 0') @@ -259,6 +347,46 @@ describe('deferred structured agent-session event sink', () => { expect(log).toHaveLength(2) }) + it('pauses and resumes the reader at the same watermarks over writes the journal has not landed', async () => { + const changes: boolean[] = [] + const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } + const deferred = createDeferredStructuredAgentSessionEventSink({ + watermarks: { + pauseQueuedBytes: 1_000_000, + maxQueuedBytes: 1_000_000, + lowQueuedBytes: 1_000_000, + pauseQueuedOperations: 3, + maxQueuedOperations: 4, + lowQueuedOperations: 1 + }, + readingControl, + onBackpressureChange: (paused) => changes.push(paused) + }) + const owed = await owingTarget(5) + deferred.bind(owed.bound) + const append = (ordinal: number) => + deferred.sink.tryAppendItem?.(identity(ordinal), BODY, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + + expect([append(0), append(1)]).toEqual([{ accepted: true }, { accepted: true }]) + expect(readingControl.pauseReading).not.toHaveBeenCalled() + expect(append(2)).toEqual({ accepted: true }) + expect(deferred.state()).toMatchObject({ backpressured: true, queuedOperations: 3 }) + expect(readingControl.pauseReading).toHaveBeenCalledOnce() + expect(append(3)).toEqual({ accepted: true }) + expect(append(4)).toEqual({ accepted: false, reason: 'backpressure' }) + + // Nothing has landed yet: every admitted write waits behind the copy it owes. + expect(owed.landed()).toEqual([]) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + expect(changes).toEqual([true, false]) + expect(readingControl.resumeReading).toHaveBeenCalledOnce() + expect(deferred.state()).toMatchObject({ backpressured: false, queuedOperations: 0 }) + expect(owed.landed()).toEqual([0, 1, 2, 3]) + await owed.dispose() + }) + it('admits a resolved append and publication as one bounded operation', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink({ @@ -393,24 +521,14 @@ describe('deferred structured agent-session event sink', () => { releaseSecond?.() }) - it('replaces a queued same-item checkpoint before it runs', async () => { - const log: Recorded[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } - - deferred.sink.appendItem(identity(0), BODY, options) - deferred.sink.appendItem(identity(1), BODY, options) - expect(deferred.state().queuedOperations).toBe(1) - - deferred.bind(target(6, log)) - await deferred.drained() - expect(log).toEqual([{ call: 'appendItem', fence: 6, ordinal: 1 }]) - }) - - it('keeps the first queued lifecycle batch for a settlement, as the journal does', async () => { - // The journal applies a settlement id once and skips any later batch with it, - // so the queue must not let a later batch replace one it has not run yet. - const log: Recorded[] = [] + it('applies the first lifecycle batch for a settlement once, in the journal', async () => { + // The journal applies a settlement id once; the sink hands both over in order. + const root = await mkdtemp(join(tmpdir(), 'orca-event-sink-settlement-')) + const journal = await openAgentSessionJournal({ + identity: JOURNAL_IDENTITY, + database: openTestJournalHostDatabase(root), + mintEpoch: () => 'epoch-1' + }) const deferred = createDeferredStructuredAgentSessionEventSink() const batch = (ordinal: number) => [ { @@ -425,34 +543,64 @@ describe('deferred structured agent-session event sink', () => { expect(deferred.sink.tryAppendLifecycleBatch?.('turn-completed:turn-1', batch(1))).toEqual({ accepted: true }) - expect(deferred.state().queuedOperations).toBe(1) - - const bound = target(6, log) - deferred.bind(bound) - await deferred.drained() + deferred.bind({ journal, fence: 1, publish: () => {} }) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) - expect( - vi - .mocked(bound.journal.appendLifecycleBatch) - .mock.calls.map(([input]) => input.mutations.map((mutation) => mutation.identity)) - ).toEqual([[identity(0)]]) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity(0)) + ]) + await journal.close() + await rm(root, { recursive: true, force: true }) }) - it('keeps a replacement checkpoint after distinct intervening operations', async () => { + it('keeps a rewritten streamed row in its place ahead of what was issued after it', async () => { + // A Codex reply checkpointed, then another row, then the reply's next checkpoint. const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const window: (() => void)[] = [] + const translator = createCodexJournalTranslator({ + sink: deferred.sink, + sessionId: 'session-1', + primaryThreadId: () => 'thread-1', + schedule: (run) => { + window.push(run) + return () => {} + } + }) + const notify = (method: string, params: Record) => + translator.handle({ + type: 'notification', + sessionId: 'session-1', + threadId: 'thread-1', + method, + params: { threadId: 'thread-1', turnId: 'turn-1', turn: { id: 'turn-1' }, ...params } + }) + const checkpoint = (text: string) => { + notify('item/agentMessage/delta', { itemId: 'reply', delta: text }) + for (const run of window.splice(0)) { + run() + } + } + notify('turn/started', {}) + notify('item/started', { item: { type: 'agentMessage', id: 'reply', text: '' } }) + checkpoint('Looking at the tests first. '.repeat(4)) + notify('item/completed', { item: { type: 'agentMessage', id: 'aside', text: 'An aside.' } }) + checkpoint('Then the build. '.repeat(40)) - deferred.sink.appendItem(identity(0), BODY, options) - deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.appendItem(identity(2), BODY, options) - deferred.bind(target(6, log)) + const bound = target(6, log) + deferred.bind(bound) await deferred.drained() - - expect(log).toEqual([ - { call: 'appendItem', fence: 6, ordinal: 1 }, - { call: 'appendItem', fence: 6, ordinal: 2 } - ]) + const written = vi.mocked(bound.journal.appendItem).mock.calls.flatMap(([id, body]) => { + const block = body.kind === 'message' ? body.blocks[0] : undefined + const text = block?.type === 'text' ? block.text : '' + return text.startsWith('Looking') || text === 'An aside.' + ? [`${text.startsWith('Looking') ? 'reply' : 'aside'} ${agentJournalItemKey(id)}`] + : [] + }) + const [reply, aside] = written + // The reply's row is created before the aside and rewritten after it; never moved behind it. + expect(reply?.startsWith('reply')).toBe(true) + expect(written).toEqual([reply, aside, reply]) }) it('coalesces provider activity as a publication without a journal write', async () => { @@ -472,6 +620,28 @@ describe('deferred structured agent-session event sink', () => { } ]) }) + + it('skips a publication handed over and then replaced, and runs its replacement after the write between them', async () => { + const owed = await owingTarget(6) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind(owed.bound) + const seen: string[] = [] + owed.bound.publish = (activity) => + seen.push(`${activity?.text ?? 'none'} over ${owed.landed().join(',')}`) + + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Thinking' }) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Checking the result' }) + // The first publication was handed over and waits behind the copy; the second replaced it. + expect(deferred.state().queuedOperations).toBe(3) + expect(seen).toEqual([]) + + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + expect(seen).toEqual(['Checking the result over 0,1']) + expect(deferred.state().queuedOperations).toBe(0) + await owed.dispose() + }) }) describe('producer linkage reaches the journal through every append path', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 121fb865db4a..eb4d9089791c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -29,14 +29,20 @@ export type StructuredAgentSessionSinkBarrier = { ok: true } | { ok: false; erro /** Linkage a producer stamps on the rows it writes. Absent on every append the * session's own agent makes, which is what makes absence mean root. */ export type StructuredAgentSessionAppendOptions = AgentJournalProducerLinkage & { - /** Pending checkpoints with this key replace one another before they run. */ - coalescingKey?: string /** Marks a critical lifecycle operation for lifecycle barriers and diagnostics. */ lifecycle?: boolean /** Host clock to stamp on the row instead of its append time. */ observedAt?: number } +export type StructuredAgentSessionPublishOptions = Pick< + StructuredAgentSessionAppendOptions, + 'lifecycle' +> & { + /** A publication still waiting with this key is replaced by this one. */ + coalescingKey?: string +} + /** An item write states which turn its row belongs to; the write that creates the row decides. */ export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { turnScope: AgentJournalTurnScope @@ -68,11 +74,7 @@ export type StructuredAgentSessionRevisionResolver = ( journal: StructuredAgentSessionRevisionJournal ) => { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } | null -/** A revision's body is derived from the row it revises, so coalescing one away would lose it. */ -export type StructuredAgentSessionRevisionOptions = Omit< - StructuredAgentSessionItemAppendOptions, - 'coalescingKey' -> +export type StructuredAgentSessionRevisionOptions = StructuredAgentSessionItemAppendOptions /** Compatibility alias for lifecycle callers that already use this resolver. */ export type StructuredAgentSessionLifecycleIdentityResolver = StructuredAgentSessionIdentityResolver @@ -91,7 +93,7 @@ export type StructuredAgentSessionEventSink = { identity: AgentJournalItemIdentity, options?: StructuredAgentSessionAppendOptions ): StructuredAgentSessionSinkAdmission - publish(options?: StructuredAgentSessionAppendOptions): void + publish(options?: StructuredAgentSessionPublishOptions): void setActivity?(activity: AgentSessionTurnActivity | null): void tryAppendItem?( identity: AgentJournalItemIdentity, @@ -145,7 +147,7 @@ export type StructuredAgentSessionEventSink = { mutations: readonly JournalLifecycleMutationInput[], options?: StructuredAgentSessionAppendOptions ): StructuredAgentSessionSinkAdmission - tryPublish?(options?: StructuredAgentSessionAppendOptions): StructuredAgentSessionSinkAdmission + tryPublish?(options?: StructuredAgentSessionPublishOptions): StructuredAgentSessionSinkAdmission /** Couples durable-queue pressure to the exact provider stream producing it. */ bindReadingControl?(control: StructuredAgentSessionReadingControl): () => void } @@ -218,8 +220,6 @@ export function createDeferredStructuredAgentSessionEventSink( queue.submit( { bytes: Buffer.byteLength(JSON.stringify({ settlementId, mutations }), 'utf8') + 512, - coalescingKey: `lifecycle:${settlementId}`, - keepsFirst: true, run: (bound) => bound.journal.appendLifecycleBatch({ settlementId, @@ -232,48 +232,41 @@ export function createDeferredStructuredAgentSessionEventSink( ) const publish = ( - options: StructuredAgentSessionAppendOptions = {} + options: StructuredAgentSessionPublishOptions = {} ): StructuredAgentSessionSinkAdmission => queue.submit( { bytes: 1, - coalescingKey: options.coalescingKey ?? 'publish', + publicationKey: options.coalescingKey ?? 'publish', run: (bound) => bound.publish() }, options ) + const appendItem: NonNullable = ( + identity, + body, + options + ) => + queue.submit( + { + bytes: estimateStructuredAgentSessionItemBytes(identity, body), + run: (bound) => + bound.journal.appendItem( + identity, + body, + structuredAgentSessionJournalAppendOptions(bound.fence, options) + ) + }, + options + ) + return { sink: { appendItem: (identity, body, options) => { - queue.submit( - { - bytes: estimateStructuredAgentSessionItemBytes(identity, body), - coalescingKey: options.coalescingKey, - run: (bound) => - bound.journal.appendItem( - identity, - body, - structuredAgentSessionJournalAppendOptions(bound.fence, options) - ) - }, - options - ) + appendItem(identity, body, options) }, - tryAppendItem: (identity, body, options) => - queue.submit( - { - bytes: estimateStructuredAgentSessionItemBytes(identity, body), - coalescingKey: options.coalescingKey, - run: (bound) => - bound.journal.appendItem( - identity, - body, - structuredAgentSessionJournalAppendOptions(bound.fence, options) - ) - }, - options - ), + tryAppendItem: appendItem, ...resolvedAppend, journalEpoch: queue.journalEpoch, journalLinkage: queue.journalLinkage, @@ -315,7 +308,7 @@ export function createDeferredStructuredAgentSessionEventSink( setActivity: (activity) => { queue.submit({ bytes: Buffer.byteLength(JSON.stringify(activity), 'utf8') + 64, - coalescingKey: 'turn-activity', + publicationKey: 'turn-activity', run: (bound) => bound.publish(activity) }) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 8fc810de2a4d..1c8be148875b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -10,6 +10,7 @@ import { AgentSessionPreSpawnError } from './structured-agent-session-adapter' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { withJournalQueueMembers } from './structured-agent-session-journal-double-test-support' function context(): StructuredAgentSessionEvictionContext & { order: string[] } { const order: string[] = [] @@ -142,7 +143,12 @@ describe('rows the provider emits while closing', () => { const sessionId = 'session-closing-rows' const sink = state.eventSinkFor(sessionId) const published: string[] = [] - sink.bind({ journal: {} as never, fence: 1, publish: () => published.push('final-flush') }) + sink.bind({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these rows are publications only, which reach nothing on the journal but its in-order read. + journal: withJournalQueueMembers({ appendItem: async () => ({}) }) as never, + fence: 1, + publish: () => published.push('final-flush') + }) await evictStructuredAgentSession({ sessionId, @@ -236,7 +242,12 @@ describe('eviction against the real sink cache', () => { const published: string[] = [] const reattached = state.eventSinkFor(sessionId) - reattached.bind({ journal: {} as never, fence: 2, publish: () => published.push('published') }) + reattached.bind({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these rows are publications only, which reach nothing on the journal but its in-order read. + journal: withJournalQueueMembers({ appendItem: async () => ({}) }) as never, + fence: 2, + publish: () => published.push('published') + }) reattached.sink.publish() await reattached.drained() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index ed2e48fd562b..974dc35d385e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -54,7 +54,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { ) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void publishRestored: (sessionId: string) => void - flushStreamedEvents: (sessionId: string) => Promise }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input const loop = new StructuredAgentSessionDeliveryLoop({ @@ -72,7 +71,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { ), onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), record: (sessionId) => deps.store.getRecord(sessionId), - flushStreamedEvents: input.flushStreamedEvents, now: () => deps.now?.() ?? Date.now() }) const adoptOpened = async ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3055bb2d8789..c26f48996edc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -22,10 +22,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' -import { - isMainAgentWorkingOnceFlushed, - performCancel -} from './structured-agent-session-turns-cancel' +import { isMainAgentWorking, performCancel } from './structured-agent-session-turns-cancel' import { admitAndRunAgentSessionMutation, type AgentSessionMutationRequest, @@ -46,7 +43,10 @@ import { type MutationPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -import { runStopWithQueuePause } from './structured-agent-session-queued-stop' +import { + runStopWithQueuePause, + withdrawQueuedForStop +} from './structured-agent-session-queued-stop' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -61,7 +61,6 @@ export type StructuredAgentSessionMutationContext = { deps: StructuredAgentSessionHostDeps sessions: Map publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void - flushStreamedEvents: (sessionId: string) => Promise /** The host's accessor, for a caller outside the session's serialize. */ conversation: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise @@ -98,7 +97,6 @@ export function mutateStructuredAgentSession( journal: () => context.sessions.get(envelope.sessionId)?.journal, prepareSession, publish: (journal) => context.publish(envelope.sessionId, journal), - flushStreamedEvents: context.flushStreamedEvents, providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, now: () => context.now() }) @@ -179,38 +177,50 @@ export function cancelStructuredAgentSessionTurn( run: (ctx) => runStopWithQueuePause(ctx, async (tookEffect) => { // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. - const withdrawn = await ctx.journal.rejectQueuedSubmissions( - ctx.fence, - agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + // Issued, not awaited: the interrupt never waits on bookkeeping, nor on an owed import. + const withdrew = withdrawQueuedForStop(ctx, () => + ctx.journal.rejectQueuedSubmissions( + ctx.fence, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + ) ) + // The pause is judged once the withdrawal has landed, so it sees each card sent back. + const tookEffectOnceWithdrawn = (): Promise => withdrew.then(tookEffect) const named = params.turnId !== undefined ? { turnId: params.turnId } : {} const child = context.sessions.get(ctx.sessionId)?.child if (child?.phase === 'starting') { // A start that may never land is the one thing here Stop has to end; the chat stays. - await tookEffect() + const effect = tookEffectOnceWithdrawn() await context.stopAgent(ctx.sessionId) + await effect return { ok: true, value: { ...named, cancelled: true } } } // A Stop naming no turn ends nothing more unless the session reads working, by the rule - // every session list and the chat's own Stop read it. - const inFlight = params.turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx)) + // every session list and the chat's own Stop read it, over the fold as it stands. + const inFlight = params.turnId !== undefined || isMainAgentWorking(ctx) const record = context.deps.store.getRecord(ctx.sessionId) if (!child || !inFlight) { - if (withdrawn.length > 0) { + // Nothing to interrupt, so the answer may wait for the withdrawal. + const withdrewAny = await withdrew + if (withdrewAny) { await tookEffect() } - return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } + return { ok: true, value: { ...named, cancelled: withdrewAny } } } - await tookEffect() - return performCancel( + const effect = tookEffectOnceWithdrawn() + const outcome = await performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, { clientOperationId: params.envelope.clientOperationId, ...named, stopChild: () => context.stopAgent(params.envelope.sessionId), - withdrewQueued: withdrawn.length > 0 + withdrewQueued: withdrew } ) + await effect + return outcome }) }, openForWrite(context, params.envelope) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 63f37a37590e..b8bb497fe7d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -91,9 +91,11 @@ export class StructuredAgentSessionHostRuntimeState { this.eventSinks.delete(sessionId) } + /** Through `currentEventSink`: a failed sink is terminal, so its old error never fails a later + * caller's barrier. */ flushEventSink(sessionId: string): Promise { return this.requireSuccessfulBarrier( - this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve({ ok: true } as const) + this.currentEventSink(sessionId)?.drained() ?? Promise.resolve({ ok: true } as const) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 46bd84905921..79542ee47e51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -301,27 +301,6 @@ describe('cancel', () => { expect(cancelTurn).not.toHaveBeenCalled() }) - it('records an unknown outcome when lifecycle draining fails and never interrupts on replay', async () => { - await attach() - const prompt = await seedApproval() - vi.spyOn(host, 'flushStreamedEvents').mockRejectedValueOnce(new Error('journal drain failed')) - const fields = { - turnId: 'turn-1', - prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } - } - const params = { - envelope: envelope('agentSession.cancel', fields), - ...fields - } - - await expect(host.cancel(CALLER, params)).rejects.toThrow('journal drain failed') - expect(await host.cancel(CALLER, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown' } - }) - expect(cancelTurn).toHaveBeenCalledTimes(1) - }) - it('records an unknown outcome when strict prompt interruption throws and never retries it', async () => { await attach() const prompt = await seedApproval() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index ae2eb3650f63..e9ccaf5ad9e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -131,8 +131,7 @@ export class StructuredAgentSessionHost { reset, structuredAgentSessionConversationFence(deps.store, sessionId) ), - publishRestored: this.clientDelivery.publishRestored, - flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) + publishRestored: this.clientDelivery.publishRestored }) this.restore = createStructuredAgentSessionHostRestore(deps, { reconcile: this.reconcileLeases, @@ -251,6 +250,7 @@ export class StructuredAgentSessionHost { return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) } + /** Test barrier: every write has landed by its call's return, so no production path needs it. */ flushStreamedEvents = (sessionId: string): Promise => this.runtimeState.flushEventSink(sessionId) @@ -273,7 +273,6 @@ export class StructuredAgentSessionHost { deps: this.deps, sessions: this.sessions, publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), - flushStreamedEvents: this.flushStreamedEvents, conversation: this.lifetime.conversation, serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-double-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-double-test-support.ts new file mode 100644 index 000000000000..dc27fc5d320a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-double-test-support.ts @@ -0,0 +1,40 @@ +// The journal members the event sink reaches besides a plain append, for tests whose journal double +// has no write queue: a read and a resolved append each run as they are issued, as on an idle queue. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { JournalItemAppendOptions } from '../agent-session-journal/journal-store-contracts' + +type AppendingDouble = { + appendItem: ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + options: JournalItemAppendOptions + ) => Promise +} + +export function withJournalQueueMembers( + journal: T +): T & { + readInOrder: (read: () => R) => Promise + appendResolvedItem: ( + resolve: () => { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } | null, + options: JournalItemAppendOptions + ) => Promise +} { + return Object.assign(journal, { + readInOrder: async (read: () => R): Promise => read(), + appendResolvedItem: async ( + resolve: () => { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } | null, + options: JournalItemAppendOptions + ): Promise => { + const resolved = resolve() + // Read at call time, so a test that swaps the double's append later is still the one called. + return resolved === null + ? null + : journal.appendItem(resolved.identity, resolved.body, options) + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 6a623428e4f0..02b1cf1c77e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -64,7 +64,6 @@ export type AgentSessionMutationRequest = { record: AgentSessionRecord ) => Promise publish: (journal: AgentSessionJournal) => void - flushStreamedEvents: (sessionId: string) => Promise providerChildPhase?: AgentSessionTurnContext['providerChildPhase'] now: () => number } @@ -189,7 +188,6 @@ function turnContext( .then(() => undefined), resolvedBy: request.callerKey, publish: () => request.publish(journal), - flushStreamedEvents: () => request.flushStreamedEvents(request.envelope.sessionId), ...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}), now: () => request.now() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 71750505fef1..4437e4280a66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -36,7 +36,6 @@ async function context(): Promise { persistOptions: async () => {}, resolvedBy: 'test', publish: () => {}, - flushStreamedEvents: async () => {}, now: () => 0 } } @@ -131,15 +130,12 @@ it.each([1, 2])( } ) -// A send's plan only accepts: it records the submission and never waits on the provider's stream -// barrier, so a sink that stalls or fails cannot delay or double a send. Handing it over is the -// delivery loop's. -it('accepts without touching the event-stream barrier or the provider', async () => { +// A send's plan only accepts: it records the submission and never reaches the provider. Handing +// it over is the delivery loop's. +it('accepts without touching the provider', async () => { const ctx = await context() const { store } = hostTestState() vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() - const barrier = vi.fn(() => new Promise(() => {})) - ctx.flushStreamedEvents = barrier const beforeRun = vi.fn() const body = hostTestMessage('Continue the interrupted work') const operation = envelope('agentSession.send', { body }) @@ -157,7 +153,6 @@ it('accepts without touching the event-stream barrier or the provider', async () dispatchState: 'pending', handoverRecorded: true }) - expect(barrier).not.toHaveBeenCalled() }) it('refuses a superseded send at acceptance, recording and dispatching nothing', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index b9a7bc7c5c47..06a4dbdeb004 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -58,8 +58,7 @@ async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: function context( journal: AgentSessionJournal, - cancelTurn: StructuredAgentSessionAdapter['cancelTurn'], - flushStreamedEvents: () => Promise + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] ): AgentSessionTurnContext { return { sessionId: 'session-1', @@ -69,7 +68,6 @@ function context( persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), - flushStreamedEvents, now: () => 1 } } @@ -78,9 +76,8 @@ describe('performCancel for a pending prompt', () => { it('refuses a stale prompt revision before reaching the provider', async () => { const { journal, itemId } = await pendingPrompt() const cancelTurn = vi.fn(async () => ({ cancelled: true })) - const flush = vi.fn(async () => undefined) - const result = await performCancel(context(journal, cancelTurn, flush), { + const result = await performCancel(context(journal, cancelTurn), { clientOperationId: 'cancel-1', turnId: 'turn-1', prompt: { itemId, expectedRevision: 2 } @@ -91,39 +88,38 @@ describe('performCancel for a pending prompt', () => { refusal: { code: 'agent_session_item_revision_stale', currentRevision: 1 } }) expect(cancelTurn).not.toHaveBeenCalled() - expect(flush).not.toHaveBeenCalled() }) - it('drains terminal lifecycle before recording a confirmed cancellation', async () => { + it("records a confirmed cancellation after the prompt's own terminal row", async () => { const { journal, itemId } = await pendingPrompt() const order: string[] = [] const cancelTurn = vi.fn(async () => { order.push('interrupt') - return { cancelled: true } - }) - const flush = vi.fn(async () => { - order.push('lifecycle') const current = journal.snapshot().items.find((item) => item.itemId === itemId)! if (current.body.kind !== 'approval') { throw new Error('expected approval prompt') } - await journal.appendItem( - PROMPT_IDENTITY, - { - ...current.body, - resolution: { - state: 'cancelled', - selectedOptionId: null, - resolvedBy: null, - resolvedAt: null - } - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) + // The provider's frame, issued during the interrupt and not yet landed when it answers. + void journal + .appendItem( + PROMPT_IDENTITY, + { + ...current.body, + resolution: { + state: 'cancelled', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + .then(() => order.push('lifecycle')) + return { cancelled: true } }) await expect( - performCancel(context(journal, cancelTurn, flush), { + performCancel(context(journal, cancelTurn), { clientOperationId: 'cancel-1', turnId: 'turn-1', prompt: { itemId, expectedRevision: 1 } @@ -138,19 +134,21 @@ describe('performCancel for a pending prompt', () => { resolveLiveTurnId: expect.any(Function), prompt: { itemId } }) - expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + const items = journal.snapshot().items + expect(items.map((item) => item.body)).toEqual([ expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), { kind: 'status', text: 'Cancellation requested.' } ]) + // Issued after the prompt's terminal row, so it lands after it. + expect(items[1]!.sequence).toBeGreaterThan(items[0]!.sequence) }) it('keeps the callback answerable when interruption is declined', async () => { const { journal, itemId } = await pendingPrompt() - const flush = vi.fn(async () => undefined) await expect( performCancel( - context(journal, async () => ({ cancelled: false }), flush), + context(journal, async () => ({ cancelled: false })), { clientOperationId: 'cancel-1', turnId: 'turn-1', @@ -159,7 +157,6 @@ describe('performCancel for a pending prompt', () => { ) ).resolves.toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: false } }) - expect(flush).not.toHaveBeenCalled() expect(journal.snapshot().items.map((item) => item.body)).toEqual([ expect.objectContaining({ resolution: expect.objectContaining({ state: 'pending' }) }), { kind: 'status', text: 'The provider had already finished this turn.' } @@ -168,17 +165,12 @@ describe('performCancel for a pending prompt', () => { it('propagates an unconfirmed adapter failure and leaves the prompt pending', async () => { const { journal, itemId } = await pendingPrompt() - const flush = vi.fn(async () => undefined) await expect( performCancel( - context( - journal, - async () => { - throw new Error('interrupt receipt lost') - }, - flush - ), + context(journal, async () => { + throw new Error('interrupt receipt lost') + }), { clientOperationId: 'cancel-1', turnId: 'turn-1', @@ -187,28 +179,8 @@ describe('performCancel for a pending prompt', () => { ) ).rejects.toThrow('interrupt receipt lost') - expect(flush).not.toHaveBeenCalled() expect(journal.snapshot().items.map((item) => item.body)).toEqual([ expect.objectContaining({ resolution: expect.objectContaining({ state: 'pending' }) }) ]) }) - - it('surfaces a lifecycle drain failure after the provider confirms interruption', async () => { - const { journal, itemId } = await pendingPrompt() - const flush = vi.fn(async () => { - throw new Error('journal drain failed') - }) - - await expect( - performCancel( - context(journal, async () => ({ cancelled: true }), flush), - { - clientOperationId: 'cancel-1', - turnId: 'turn-1', - prompt: { itemId, expectedRevision: 1 } - } - ) - ).rejects.toThrow('journal drain failed') - expect(journal.snapshot().items).toHaveLength(1) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index aa9d31cbf105..027fa5a8b37e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -154,18 +154,19 @@ describe('drain', () => { it('takes no serialized drain step while the session is working, then drains when the work settles', async () => { const working = await workingSend() - const flush = vi.spyOn(host, 'flushStreamedEvents') + // Read only by a drain step, so it marks one. + const step = vi.spyOn(JournalQueuedMessages.prototype, 'deliveredByEchoOwed') const queued = await send('waits for the turn', 'queue-if-active').result await send('and another', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } // Every wake during the turn is answered by the pre-check, not a step. - expect(flush).not.toHaveBeenCalled() + expect(step).not.toHaveBeenCalled() await settleAccepted(working, 'a') const draftId = queued.value.queued.messageId await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) - expect(flush).toHaveBeenCalled() + expect(step).toHaveBeenCalled() }) it('a refused conversion returns the card with its stored reason, and an idle send overtakes a lone returned card (N1)', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index b6d9d2a90beb..0b0b96902143 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -257,9 +257,6 @@ export type QueuedMessageDrainDeps = { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null serialize: (sessionId: string, task: () => Promise) => Promise - /** The streamed-event barrier: a turn-open already accepted by the host is - * committed before the gates are read, so no stored busy flag is needed. */ - flushStreamedEvents: (sessionId: string) => Promise conversationFence: (sessionId: string) => number /** The consumed submission is ordinary #22821 work from here on. */ wakeDelivery: (sessionId: string) => void @@ -284,7 +281,7 @@ export class StructuredAgentSessionQueuedMessageDrain { } // Cheap pre-check so token streams do not pay a serialized step per delta. // Skipping while working is safe: whatever ends the work is itself a commit - // that schedules again, and the step re-reads every gate after its flush. + // that schedules again, and the step re-reads every gate from the fold. try { if ( !journal.queuedMessages.settlementOwed() && @@ -321,7 +318,6 @@ export class StructuredAgentSessionQueuedMessageDrain { if (!session || session.journal.isReadOnly) { return } - await this.deps.flushStreamedEvents(sessionId) const journal = session.journal if (journal.queuedMessages.settlementOwed() || journal.queuedMessages.deliveredByEchoOwed()) { // A live per-row hook was skipped; heal now, before a draft sends, rather than at reopen. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 1349a6a178e0..3fd04817b7a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -437,20 +437,25 @@ describe('Resume', () => { }) describe('a failed Stop', () => { - it('records nothing when it fails before taking effect, so the queue sends as if no Stop was pressed', async () => { - const working = await rig.workingSend() + // Withdrawing is bookkeeping: its failure is reported, and the Stop still interrupts and pauses. + it('still takes effect when its withdrawal fails, and pauses the queue', async () => { + await rig.workingSend() const draftId = await queuedDraft('queued before the stop') const reject = vi .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') .mockRejectedValueOnce(new Error('disk full')) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { - await expect(rig.stop()).rejects.toThrow('disk full') + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(warned).toHaveBeenCalledWith( + "[agent-session] Stop's withdrawal skipped:", + expect.objectContaining({ error: 'disk full' }) + ) } finally { reject.mockRestore() + warned.mockRestore() } - expect(await rig.queuePause()).toBeNull() - await rig.settleAccepted(working, 'working') - await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await expectPaused(draftId) }) it('keeps its pause when it fails after the interrupt reached the agent', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 16a9b4aeaf77..75a30b5e8a19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -52,12 +52,31 @@ export async function runStopWithQueuePause( } // Recorded only over a card it holds back — judged in its own transaction, which // still counts an owed return to waiting if that heal failed. - await queuedMessages - .recordPause('stopped') - .catch((error: unknown) => report(ctx, 'queue pause', error)) + try { + await queuedMessages.recordPause('stopped') + } catch (error) { + report(ctx, 'queue pause', error) + } }) } +/** The Stop's withdrawal of every queued send, issued at once and never awaited ahead of the + * interrupt. Bookkeeping: one that fails is reported and withdrew nothing. */ +export function withdrawQueuedForStop( + ctx: AgentSessionTurnContext, + withdraw: () => Promise +): Promise { + const failed = (error: unknown): boolean => { + report(ctx, 'withdrawal', error) + return false + } + try { + return withdraw().then((withdrawn) => withdrawn.length > 0, failed) + } catch (error) { + return Promise.resolve(failed(error)) + } +} + function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { console.warn(`[agent-session] Stop's ${step} skipped:`, { sessionId: ctx.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index 2b193d5cb82f..1e06064e7695 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -29,7 +29,6 @@ export function wireStructuredAgentSessionQueuedMessages( sessions, getRecord: (sessionId) => context().deps.store.getRecord(sessionId), serialize: (sessionId, task) => context().serialize(sessionId, task), - flushStreamedEvents: (sessionId) => context().flushStreamedEvents(sessionId), conversationFence: (sessionId) => structuredAgentSessionConversationFence(context().deps.store, sessionId), wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts index 403cb08a4053..c85d246247eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -99,7 +99,9 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d expect(await rig.drafts()).toEqual([]) }) -it('a Stop that fails after withdrawing a consumed draft releases it, and it sends again under a fresh id', async () => { +// Bookkeeping never fails a Stop: a withdrawal that throws is reported and counts as nothing +// withdrawn, so no pause holds the card it did send back, and that card sends again. +it('a Stop whose withdrawal throws after landing still answers; the draft it released sends again under a fresh id', async () => { const working = await rig.workingSend() const a = await queuedDraft('A') let release: () => void = () => undefined @@ -120,10 +122,16 @@ it('a Stop that fails after withdrawing a consumed draft releases it, and it sen } return withdrawn }) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { - await expect(rig.stop()).rejects.toThrow('disk full') + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(warned).toHaveBeenCalledWith( + "[agent-session] Stop's withdrawal skipped:", + expect.objectContaining({ error: 'disk full' }) + ) } finally { failing.mockRestore() + warned.mockRestore() release() } expect((await rig.submission(firstA))?.dispatchState).toBe('rejected') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index d3c15de933c7..7a43b224a6c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -1,11 +1,15 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventTarget, @@ -26,26 +30,37 @@ function textOf(body: AgentJournalItemBody | undefined): string { return block?.type === 'text' ? block.text : '' } -/** A journal whose appends land a tick later, so an unserialized read would race. */ -function journalTarget(rows: Map): StructuredAgentSessionEventTarget { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal methods the resolved paths call. - const journal = { - epoch: 'e', - appendItem: vi.fn(async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - await new Promise((resolve) => setTimeout(resolve, 1)) - rows.set(agentJournalItemKey(identity), body) - return { cursor: { epoch: 'e', sequence: rows.size } } - }), - visitItems: (visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void) => { - let sequence = 0 - for (const [itemId, body] of rows) { - visit(itemId, ++sequence, body) - } +let root = '' +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-resolved-append-')) + // Its copy owed, so every write handed over waits in the queue: a resolver that read at submit + // would read the row before the revisions ahead of it had landed. + ;({ journal } = await openJournalOwingImport({ + stateDirectory: root, + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } } - } as unknown as AgentSessionJournal + })) +}) + +afterEach(async () => { + await journal.close() + await rm(root, { recursive: true, force: true }) +}) + +/** The real journal: each resolved write reads the fold at its own place in the write queue. */ +function journalTarget(): StructuredAgentSessionEventTarget { return { journal, fence: 1, publish: vi.fn() } } +const rowText = (): string => textOf(journal.itemBody(agentJournalItemKey(ROW)) ?? undefined) + const appendSuffix = (suffix: string): StructuredAgentSessionRevisionResolver => (journal) => { @@ -60,7 +75,6 @@ const appendSuffix = describe('resolved revisions', () => { it('reads the row as the journal holds it when each queued revision runs', async () => { - const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('abc')) for (const suffix of ['a', 'b', 'c']) { @@ -74,25 +88,23 @@ describe('resolved revisions', () => { } // Three revisions of one row stay three operations; none replaces another. expect(deferred.state().queuedOperations).toBe(3) - deferred.bind(journalTarget(rows)) + deferred.bind(journalTarget()) await expect(deferred.drained()).resolves.toEqual({ ok: true }) - expect(textOf(rows.get(agentJournalItemKey(ROW)))).toBe('abc') + expect(rowText()).toBe('abc') }) it('skips a revision that resolves to nothing', async () => { - const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() deferred.sink.tryReviseResolvedItem?.(1_000, () => null, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - const target = journalTarget(rows) - deferred.bind(target) + const before = journal.cursor() + deferred.bind(journalTarget()) await expect(deferred.drained()).resolves.toEqual({ ok: true }) - expect(target.journal.appendItem).not.toHaveBeenCalled() + expect(journal.cursor()).toEqual(before) }) it('publishes a revision in the operation that writes it, within the same reservation', async () => { - const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a'), { @@ -101,11 +113,9 @@ describe('resolved revisions', () => { deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, () => null, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - const target = journalTarget(rows) + const target = journalTarget() const published: string[] = [] - vi.mocked(target.publish).mockImplementation(() => - published.push(textOf(rows.get(agentJournalItemKey(ROW)))) - ) + vi.mocked(target.publish).mockImplementation(() => published.push(rowText())) deferred.bind(target) await expect(deferred.drained()).resolves.toEqual({ ok: true }) // Published once, after the append: the revision that resolves to nothing publishes nothing. @@ -113,7 +123,6 @@ describe('resolved revisions', () => { }) it('refuses a resolved write larger than the reservation it was admitted with', async () => { - const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) deferred.sink.tryReviseResolvedItem?.( @@ -124,8 +133,8 @@ describe('resolved revisions', () => { }), { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - deferred.bind(journalTarget(rows)) + deferred.bind(journalTarget()) await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) - expect(rows.size).toBe(0) + expect(journal.itemBody(agentJournalItemKey(ROW))).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts index eb2fbea97fb7..d3640db2a0cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts @@ -13,8 +13,8 @@ import type { StructuredAgentSessionSinkQueue } from './structured-agent-session type ResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } -/** Resolve a queued item against the journal bound at execution. The queue runs - * one operation at a time, so what the resolver reads is what the append revises. */ +/** Resolve an item against the journal it lands in, at its own place in that journal's write + * queue, so what the resolver reads is every write issued before it and none after. */ export function createStructuredAgentSessionResolvedAppend( queue: StructuredAgentSessionSinkQueue ): Required< @@ -30,33 +30,37 @@ export function createStructuredAgentSessionResolvedAppend( const submit = ( reservedBytes: number, resolve: (journal: StructuredAgentSessionRevisionJournal) => ResolvedItem | null, - options: Omit, - publish: boolean + options: StructuredAgentSessionItemAppendOptions, + input: { publish: boolean; lifecycle?: true; overflow: string } ) => queue.submit( { bytes: reservedBytes, + ...(input.lifecycle ? { lifecycle: true } : {}), run: async (bound) => { - const resolved = resolve(bound.journal) - if (resolved === null) { - return - } - const bytes = estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) - if (bytes + (publish ? 1 : 0) > reservedBytes) { - throw new Error('structured agent-session resolved item exceeded its reserved size') - } - await bound.journal.appendItem( - resolved.identity, - resolved.body, + const landed = await bound.journal.appendResolvedItem( + () => { + const resolved = resolve(bound.journal) + if ( + resolved !== null && + estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) + + (input.publish && !input.lifecycle ? 1 : 0) > + reservedBytes + ) { + throw new Error(input.overflow) + } + return resolved + }, structuredAgentSessionJournalAppendOptions(bound.fence, options) ) - if (publish) { + if (landed !== null && input.publish) { bound.publish() } } }, - options + input.lifecycle ? { lifecycle: true } : options ) + const ITEM_OVERFLOW = 'structured agent-session resolved item exceeded its reserved size' const identityOnly = (publish: boolean) => ((identitySizeBound, body, resolveIdentity, options) => submit( @@ -66,39 +70,28 @@ export function createStructuredAgentSessionResolvedAppend( return identity === null ? null : { identity, body } }, options, - publish + { publish, overflow: ITEM_OVERFLOW } )) satisfies NonNullable return { tryAppendResolvedItem: identityOnly(false), tryAppendResolvedItemAndPublish: identityOnly(true), tryReviseResolvedItem: (reservedBytes, resolve, options) => - submit(reservedBytes, resolve, options, false), + submit(reservedBytes, resolve, options, { publish: false, overflow: ITEM_OVERFLOW }), tryReviseResolvedItemAndPublish: (reservedBytes, resolve, options) => - submit(reservedBytes + 1, resolve, options, true), - tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options) => { - const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) - return queue.submit( + submit(reservedBytes + 1, resolve, options, { publish: true, overflow: ITEM_OVERFLOW }), + tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options) => + submit( + estimateStructuredAgentSessionItemBytes(identitySizeBound, body), + (journal) => { + const identity = resolveIdentity(journal) + return identity === null ? null : { identity, body } + }, + options, { - bytes, + publish: true, lifecycle: true, - run: async (bound) => { - const identity = resolveIdentity(bound.journal) - if (identity === null) { - return - } - if (estimateStructuredAgentSessionItemBytes(identity, body) > bytes) { - throw new Error('structured agent-session item identity exceeded its reserved size') - } - await bound.journal.appendItem( - identity, - body, - structuredAgentSessionJournalAppendOptions(bound.fence, options) - ) - bound.publish() - } - }, - { lifecycle: true } + overflow: 'structured agent-session item identity exceeded its reserved size' + } ) - } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 35e7d5af2e27..08b5ccb0c809 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -261,6 +261,26 @@ describe('host rewind', () => { expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) }) + // The stream's failure is the stream's to recover from; its stale error is not the rewind's. + it("rewinds after the chat's event sink failed, its error never failing the rewind", async () => { + const target = await seed() + const request = await params(target) + const refused = vi + .spyOn(AgentSessionJournal.prototype, 'appendItem') + .mockRejectedValueOnce(new Error('disk full')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + sink.appendItem( + { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'tip', ordinal: 1 }, + hostTestMessage('refused'), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await vi.waitFor(() => expect(refused).toHaveBeenCalled()) + refused.mockRestore() + + expect(await host.rewind(caller, request)).toMatchObject({ ok: true }) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) + }) + it('refuses a rewind racing an active turn before provider execution', async () => { const target = await seed() sink.appendItem( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index e855ae97ab27..1f476313af77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -42,7 +42,6 @@ export async function rewindStructuredAgentSession( prepareSession: openWithAgent(context, params.envelope), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), - flushStreamedEvents: context.flushStreamedEvents, now: context.now, plan: { method: 'agentSession.rewind', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 1925c13378ee..148b643efb78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -63,7 +63,6 @@ describe('structured send idempotency', () => { persistOptions: async () => undefined, resolvedBy: 'caller', publish: vi.fn(), - flushStreamedEvents: async () => undefined, now: () => 1 }, input @@ -104,7 +103,6 @@ describe('structured send idempotency', () => { persistOptions: async () => undefined, resolvedBy: 'caller', publish: vi.fn(), - flushStreamedEvents: async () => undefined, now: () => 1 } const input = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts new file mode 100644 index 000000000000..6e184aef520d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -0,0 +1,115 @@ +// A Stop's interrupt never waits on the journal. Its withdrawal and its queue pause are bookkeeping: +// one that throws is reported and the Stop still interrupts. Its writes may wait behind owed work; +// they are issued, and the interrupt goes out before they land. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' + +let host: StructuredAgentSessionHost +let acquire: Mock +let cancelTurn: Mock +let warned: ReturnType + +beforeEach(() => { + ;({ host, acquire, cancelTurn } = hostTestState()) + warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +async function runningTurn(): Promise { + await attach() + acquire.mock.calls + .at(-1)![0] + .events!.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 }, + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('no open journal') + } + // The turn row landed when the provider's event was handed over. + expect(journal.activeTurnId()).toBe('turn-1') + return journal +} + +function stop(fields: { turnId?: string }) { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) +} + +const MALFORMED = 'database disk image is malformed' + +describe.each([ + ['naming no turn', {}], + ['naming its turn', { turnId: 'turn-1' }] +])('a Stop %s', (_label, fields) => { + it('interrupts when withdrawing the queued sends throws, and reports it', async () => { + const journal = await runningTurn() + vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(() => { + throw new Error(MALFORMED) + }) + + expect(await stop(fields)).toMatchObject({ ok: true }) + expect(cancelTurn).toHaveBeenCalledOnce() + expect(warned).toHaveBeenCalledWith( + "[agent-session] Stop's withdrawal skipped:", + expect.objectContaining({ error: MALFORMED }) + ) + }) + + it('interrupts when recording its queue pause throws, and reports it', async () => { + const journal = await runningTurn() + vi.spyOn(journal.queuedMessages, 'recordPause').mockImplementation(() => { + throw new Error(MALFORMED) + }) + + expect(await stop(fields)).toMatchObject({ ok: true }) + expect(cancelTurn).toHaveBeenCalledOnce() + expect(warned).toHaveBeenCalledWith( + "[agent-session] Stop's queue pause skipped:", + expect.objectContaining({ error: MALFORMED }) + ) + }) + + // The open pays an import owed before the Stop, so the work falls owed at the Stop's first write: + // the one moment the Stop's own writes can wait behind it. + it('interrupts before owed work its writes wait behind is paid', async () => { + const journal = await runningTurn() + const owed = Promise.withResolvers() + const withdraw = journal.rejectQueuedSubmissions.bind(journal) + vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { + journal['queue'].owe(() => owed.promise) + return withdraw(...args) + }) + let answered = false + const stopping = stop(fields).finally(() => { + answered = true + }) + try { + await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce()) + expect(journal.importPending).toBe(true) + expect(answered).toBe(false) + } finally { + owed.resolve() + } + expect(await stopping).toMatchObject({ ok: true }) + expect(journal.importPending).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts new file mode 100644 index 000000000000..0103973bdd33 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts @@ -0,0 +1,153 @@ +// Text an agent has streamed stays ahead of a host write issued after it, even inside the window +// that coalesces its deltas: the item takes its place in the journal when its first delta arrives, +// and the window only delays the snapshot of its text. Driven through the real host and journal, +// with the real Claude and Codex translators fed synthetic frames; the provider child is a double. + +import { beforeEach, describe, expect, it, type Mock } from 'vitest' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' +import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' + +let host: StructuredAgentSessionHost +let acquire: Mock + +beforeEach(() => { + ;({ host, acquire } = hostTestState()) +}) + +const STREAMED = 'Looking at the tests first.' + +type Stream = { openTurn: () => void; streamText: (text: string) => void } + +/** The coalescing window, closed only when the test says so. */ +function heldWindow() { + const pending: (() => void)[] = [] + return { + schedule: (run: () => void) => { + pending.push(run) + return () => { + const index = pending.indexOf(run) + if (index !== -1) { + pending.splice(index, 1) + } + } + }, + close: () => { + for (const run of pending.splice(0)) { + run() + } + } + } +} + +function codexStream(sink: StructuredAgentSessionEventSink, schedule: Schedule): Stream { + const translator = createCodexJournalTranslator({ + sink, + sessionId: SESSION, + primaryThreadId: () => THREAD, + schedule + }) + const notify = (method: string, params: Record) => + translator.handle({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params: { threadId: THREAD, turnId: 'turn-1', turn: { id: 'turn-1' }, ...params } + }) + return { + openTurn: () => { + notify('turn/started', {}) + notify('item/started', { item: { type: 'agentMessage', id: 'reply', text: '' } }) + }, + streamText: (text) => notify('item/agentMessage/delta', { itemId: 'reply', delta: text }) + } +} + +function claudeStream(sink: StructuredAgentSessionEventSink, schedule: Schedule): Stream { + const translator = createClaudeJournalTranslator({ sink, schedule }) + let frames = 0 + const streamEvent = (event: Record) => + translator.handle({ + type: 'message', + sessionId: SESSION, + message: { + type: 'stream_event', + uuid: `frame-${++frames}`, + session_id: 'claude-session', + parent_tool_use_id: null, + event + } + }) + return { + openTurn: () => { + streamEvent({ + type: 'message_start', + message: { id: 'message-1', role: 'assistant', content: [] } + }) + streamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' } + }) + }, + streamText: (text) => + streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }) + } +} + +type Schedule = ReturnType['schedule'] + +function describeRow(body: AgentJournalItemBody): string { + if (body.kind === 'status') { + return `note: ${body.text}` + } + if (body.kind === 'message') { + const block = body.blocks[0] + return `text: ${block?.type === 'text' ? block.text : ''}` + } + return body.kind +} + +describe.each([ + ['Codex', codexStream], + ['Claude', claudeStream] +])('%s text streamed inside the coalescing window', (_provider, stream) => { + it('lands ahead of the note of a Stop issued in that window', async () => { + await attach() + const window = heldWindow() + const provider = stream(acquire.mock.calls.at(-1)![0].events!, window.schedule) + provider.openTurn() + provider.streamText(STREAMED) + const journal: AgentSessionJournal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const turnId = journal.activeTurnId() + expect(turnId).not.toBeNull() + + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId }), + turnId: turnId! + }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + window.close() + + const rows = journal + .snapshot() + .items.map((item) => describeRow(item.body)) + .filter((row) => row !== 'turn') + expect(rows).toEqual([`text: ${STREAMED}`, 'note: Cancellation requested.']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index c9cdaa997b13..16c3e9d9ceb5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -64,8 +64,7 @@ function appendGoalRow( function context( journal: AgentSessionJournal, - adapter: Partial, - flushStreamedEvents: () => Promise = async () => undefined + adapter: Partial ): AgentSessionTurnContext { return { sessionId: 'session-1', @@ -76,7 +75,6 @@ function context( persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), - flushStreamedEvents, now: () => 1 } } @@ -258,19 +256,19 @@ describe('performThreadGoalChange', () => { ]) }) - it('drains accepted provider events before deciding whether a set replaces a goal', async () => { + it('reads a goal the provider reported, still landing when the set arrives, as the one it replaces', async () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - // The goal the provider reported is still in the deferred sink when the set arrives. - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }, async () => { - await appendGoalRow(journal, { status: 'active' }) - }) + const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + // Issued, not yet landed: the set's read takes its place behind it in the journal's queue. + const landing = appendGoalRow(journal, { status: 'active' }) await performThreadGoalChange(ctx, { clientOperationId: 'op-10', change: { kind: 'set', objective: 'Ship the tests' } }) + await landing expect(changeThreadGoal).toHaveBeenCalledWith(expect.objectContaining({ replacesGoal: true })) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index a779ba51abe8..b06aa0e48f88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -54,9 +54,8 @@ export async function performThreadGoalChange( const identity = objectiveIdentity(input.clientOperationId) let replacesGoal = false if (change.kind === 'set') { - // A goal transition the host accepted but has not journaled yet decides this too. - await ctx.flushStreamedEvents() - // Read before the objective row lands: that row is a message, not a goal transition. + // Read before the objective row lands: that row is a message, not a goal transition. A goal + // transition the host accepted is already in the fold: it landed at its call. replacesGoal = ctx.journal.threadGoal() !== null } // Journal first: an active goal starts provider work at once, and the objective diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index eb3784b0a1d7..b1904070578f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -13,16 +13,11 @@ import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -/** Claude's echo accepts a send one sink write before its turn row lands, so read after the drain. - * A failed drain reads working: bookkeeping never talks a Stop out of stopping. */ -export async function isMainAgentWorkingOnceFlushed( - ctx: Pick -): Promise { - try { - await ctx.flushStreamedEvents() - } catch { - return true - } +/** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads + * it without waiting on the write queue. */ +export function isMainAgentWorking( + ctx: Pick +): boolean { return isStructuredAgentSessionMainAgentWorking( ctx.journal.activeTurnId(), ctx.journal.submissions(), @@ -41,8 +36,9 @@ export async function performCancel( prompt?: { itemId: string; expectedRevision: number } /** Ends the provider child, for a running command the provider did not take the Stop on. */ stopChild?: () => Promise - /** The host already withdrew queued messages for this Stop. */ - withdrewQueued?: boolean + /** Whether the host's withdrawal of queued messages for this Stop withdrew any; awaited only + * after the interrupt. */ + withdrewQueued?: Promise } ): Promise> { if (input.prompt) { @@ -91,7 +87,7 @@ export async function performCancel( ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) }) cancelled = outcome.cancelled - if (!cancelled && input.withdrewQueued && !(await isMainAgentWorkingOnceFlushed(ctx))) { + if (!cancelled && (await input.withdrewQueued) === true && !isMainAgentWorking(ctx)) { // A Stop that withdrew what was queued and left nothing working ended what it was sent for, // named or not. The journal judges it: providers differ on refusing a turn that has ended. cancelled = true @@ -126,9 +122,6 @@ export async function performCancel( cancelled = true note = { kind: 'status', text: 'Cancellation requested.' } } - if (cancelled && input.prompt) { - await ctx.flushStreamedEvents() - } const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } if (input.scope || note === null) { return { ok: true, value } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 2a9ed84012b7..0192edf39b53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -7,6 +7,8 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session- import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -55,7 +57,6 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), - flushStreamedEvents: async () => undefined, now: () => 1 } @@ -109,7 +110,6 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), - flushStreamedEvents: async () => undefined, now: () => 1 } @@ -156,7 +156,6 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), - flushStreamedEvents: async () => undefined, now: () => 1 } @@ -189,7 +188,6 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), - flushStreamedEvents: async () => undefined, now: () => 1 } @@ -221,7 +219,6 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), - flushStreamedEvents: async () => undefined, now: () => 1 } @@ -250,7 +247,7 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( async function cancelWith( outcome: Awaited>, input: { turnId?: string; withdrewQueued?: boolean }, - turnRow: 'none' | 'running' | 'lands-on-flush' = 'none' + turnRow: 'none' | 'running' | 'landing' = 'none' ) { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-report-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) @@ -272,6 +269,8 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( if (turnRow === 'running') { await openTurn() } + // Issued, not yet landed: the Stop's read takes its place behind it in the journal's queue. + const landing = turnRow === 'landing' ? openTurn() : null const ctx: AgentSessionTurnContext = { sessionId: 'session-1', journal, @@ -287,14 +286,15 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), - flushStreamedEvents: async () => { - if (turnRow === 'lands-on-flush') { - await openTurn() - } - }, now: () => 1 } - const result = await performCancel(ctx, { clientOperationId: 'cancel-report-1', ...input }) + const { withdrewQueued, ...named } = input + const result = await performCancel(ctx, { + clientOperationId: 'cancel-report-1', + ...named, + ...(withdrewQueued === undefined ? {} : { withdrewQueued: Promise.resolve(withdrewQueued) }) + }) + await landing const rows = journal .snapshot() .items.flatMap((item) => @@ -317,11 +317,11 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( expect(reported.rows).not.toContain('The provider had already finished this turn.') }) - it('reads the journal after its streamed rows land: a turn whose send was accepted first is still working', async () => { + it('reads the journal behind its streamed rows: a turn whose send was accepted first is still working', async () => { const reported = await cancelWith( { cancelled: false }, { turnId: 'turn-0', withdrewQueued: true }, - 'lands-on-flush' + 'landing' ) expect(reported).toEqual({ cancelled: false, @@ -329,3 +329,60 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( }) }) }) + +describe('the note a Stop writes', () => { + it('belongs to the turn whose row was emitted just before the Stop, with no flush', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-note-scope-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: () => {} }) + const turnIdentity = { + provider: 'legacy' as const, + agent: 'codex' as const, + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' + } + // An earlier streamed row still ahead, then the turn row the Stop names. + deferred.sink.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-0', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Earlier.' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + deferred.sink.appendItem( + turnIdentity, + { + kind: 'status', + text: 'Agent is working…', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const ctx: AgentSessionTurnContext = { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + closeSession: vi.fn(), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } + + await performCancel(ctx, { clientOperationId: 'cancel-scope-1', turnId: 'turn-1' }) + await deferred.drained() + + const note = journal + .snapshot() + .items.find( + (item) => item.body.kind === 'status' && item.body.text === 'Cancellation requested.' + ) + expect(note?.turnScope).toEqual({ kind: 'turn', turnItemId: agentJournalItemKey(turnIdentity) }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 13aa9d72aea0..2be01ea79036 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -57,8 +57,6 @@ export type AgentSessionTurnContext = { /** Republishes state kept outside the journal, such as the record's options or rewind phase. * Journal appends reach readers on their own. */ publish: () => void - /** Drains provider lifecycle already accepted by the execution host. */ - flushStreamedEvents: () => Promise /** What the host holds about the child this dispatch is for, read at the moment it is needed. */ providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined /** Who a Stop's refusal row names. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 3f758f81607b..b40d9175c788 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -88,8 +88,8 @@ export async function settleUnexpectedStructuredAgentSessionExit< } const stableSettlementId = providerExitSettlementId(unexpectedEvent) - const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) try { + // The exited child's own writes land first: its dead generation is settled from all of them. try { const barrier = await context.flushLifecycle(unexpectedEvent.sessionId) if (!barrier.ok) { @@ -98,6 +98,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< } catch (error) { context.onBarrierError?.(unexpectedEvent.sessionId, error) } + const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) await retryUnexpectedExitSettlement({ context, event: unexpectedEvent, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index 4e4e42c46e44..48eb83e44257 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -15,10 +15,7 @@ export class StructuredConversationCommandController { readonly pending = new Map() constructor( private readonly context: () => StructuredAgentSessionMutationContext, - private readonly host: Pick< - StructuredAgentSessionHost, - 'attach' | 'flushStreamedEvents' | 'waitForSendSettlement' - > + private readonly host: Pick ) {} send = ( caller: StructuredAgentSessionCaller, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 065ead6e3433..1ed836b974ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -134,7 +134,7 @@ function otherCallersClearIsLive( export function runStructuredConversationCommand( context: StructuredAgentSessionMutationContext, - host: Pick, + host: Pick, caller: StructuredAgentSessionCaller, params: ConversationCommandParams ): Promise> { @@ -157,7 +157,6 @@ export function runStructuredConversationCommand( prepareSession: openWithAgent(context, params.envelope), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), - flushStreamedEvents: context.flushStreamedEvents, now: context.now, plan: { method: 'agentSession.conversationCommand', @@ -175,7 +174,6 @@ export function runStructuredConversationCommand( // changed nothing and runs again, finishing the replacement its earliest try started. rerunWhenReplayMissing: () => command === 'clear', run: async (ctx) => { - await host.flushStreamedEvents(sessionId) const record = store.getRecord(sessionId)! const blocked = conversationCommandBlocked(ctx, record) if (blocked) { diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts new file mode 100644 index 000000000000..6de6683938b6 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -0,0 +1,210 @@ +// Codex's own frames go through the event sink; the settlements they prove (a failed turn's +// rejection, an echo's acceptance) are written straight to the journal. Each settlement must land +// after the frame that proved it, even with an earlier streamed write issued in the same read. +// Driven through the shipped host, journal and Codex adapter; only the Codex child is fake. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + 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 { + liveTestJournalRows, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const CALLER = { callerKey: 'codex-settlement-order-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let turns: ReturnType +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +/** One field of a stored row's JSON, read without trusting its shape. */ +function field(value: unknown, ...path: string[]): unknown { + let current = value + for (const key of path) { + current = + typeof current === 'object' && current !== null ? Reflect.get(current, key) : undefined + } + return current +} + +/** The sequence of the first row that `matches`, or of the lifecycle batch carrying it. */ +function seqOf(matches: (row: unknown) => boolean): number | undefined { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, SESSION).find((stored) => { + const row: unknown = JSON.parse(stored.rowJson) + const mutations = field(row, 'mutations') + return ( + matches(row) || + (field(row, 'kind') === 'lifecycle-batch' && + Array.isArray(mutations) && + mutations.some((mutation) => matches(mutation))) + ) + })?.seq +} + +const dispatchRow = (clientMessageId: string, state: string) => (row: unknown) => + field(row, 'kind') === 'dispatch' && + field(row, 'clientMessageId') === clientMessageId && + field(row, 'state') === state + +const turnRow = (row: unknown): boolean => + field(row, 'kind') === 'item' && field(row, 'body', 'kind') === 'turn' + +/** An assistant reply Codex finishes in the same read as the frame under test. */ +function streamedReplyAhead(): void { + handlers?.onNotification?.('item/completed', { + threadId: THREAD, + turn: { id: 'turn-1' }, + item: { type: 'agentMessage', id: 'item-reply', text: 'Done looking.' } + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-settlement-order-')) + answers = 0 + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a settlement Codex proves keeps its place behind the frame that proved it', () => { + it('rejects a send only after the failed turn that refused it has ended', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + streamedReplyAhead() + turns.end('failed', 'The model is overloaded.') + + await vi.waitFor(() => expect(seqOf(dispatchRow(sent, 'rejected'))).toBeDefined()) + await host.flushStreamedEvents(SESSION) + const ended = seqOf((row) => turnRow(row) && field(row, 'body', 'state') !== 'running') + expect(ended).toBeDefined() + expect(seqOf(dispatchRow(sent, 'rejected'))).toBeGreaterThan(ended!) + }) + + it('accepts a send only after the echo that proves it has landed', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + streamedReplyAhead() + turns.echo(sent) + + await vi.waitFor(() => expect(seqOf(dispatchRow(sent, 'accepted'))).toBeDefined()) + await host.flushStreamedEvents(SESSION) + // The echo reconciles into the send's own bubble: its row is the turn now naming that send. + const echoed = seqOf( + (row) => turnRow(row) && field(row, 'body', 'userItemId') === `orca:${sent}` + ) + expect(echoed).toBeDefined() + expect(seqOf(dispatchRow(sent, 'accepted'))).toBeGreaterThan(echoed!) + }) +}) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index efc2f786c99e..ded5796f91e3 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -597,9 +597,9 @@ describe('a structured codex session over agentSession.*', () => { codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Two ' }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'files.' }) await drainStreamedEvents() - // The 60ms window has not elapsed, so no half-written row reached the - // journal — the coalescer is holding both deltas. - expect(itemsOf(stream).filter((item) => textOf(item).startsWith('Two'))).toEqual([]) + // The first delta made the row at once; the 60ms window still holds the second. + const streamed = itemsOf(stream).filter((item) => textOf(item).startsWith('Two')) + expect(streamed.map(textOf)).toEqual(['Two ']) codex.notify('item/completed', { item: { type: 'agentMessage', id: 'item-1', text: 'Two files.' } @@ -790,12 +790,6 @@ describe('a structured codex session over agentSession.*', () => { }) await drainStreamedEvents() - codex.notify('item/agentMessage/delta', { - threadId: THREAD, - turnId: TURN, - itemId: 'item-final', - delta: 'Final text before shutdown.' - }) const host = getStructuredAgentSessionHost() const journal = ( host as unknown as { sessions: Map } @@ -808,6 +802,13 @@ describe('a structured codex session over agentSession.*', () => { await appendGate.promise return originalAppend(...args) }) + // The text's first delta writes its row at once: still landing when teardown begins. + codex.notify('item/agentMessage/delta', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-final', + delta: 'Final text before shutdown.' + }) let stopped = false const stopping = stopStructuredAgentSessionRuntime().then(() => { From 5179d9271926d3f66e0f21db0bc2c39a99253e5c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:39:55 -0700 Subject: [PATCH 13/27] fix(native-chat): a failed Stop holds the lane until its queue pause lands A Stop whose note write or stop call failed skipped the wait for its withdrawal and pause, so the lane freed first; with writes queued behind owed work, the drain could hand the waiting card to the agent after Stop. --- ...structured-agent-session-host-mutations.ts | 33 ++++++++------ ...session-queued-message-rig.test-fixture.ts | 9 ++++ ...ed-agent-session-queued-pause-lift.test.ts | 43 +++++++++++++++++++ .../structured-agent-session-queued-stop.ts | 2 +- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index c26f48996edc..f4d9ee29a814 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -193,8 +193,12 @@ export function cancelStructuredAgentSessionTurn( if (child?.phase === 'starting') { // A start that may never land is the one thing here Stop has to end; the chat stays. const effect = tookEffectOnceWithdrawn() - await context.stopAgent(ctx.sessionId) - await effect + try { + await context.stopAgent(ctx.sessionId) + } finally { + // Even a failed Stop holds the lane until its pause lands: the drain shares the lane. + await effect + } return { ok: true, value: { ...named, cancelled: true } } } // A Stop naming no turn ends nothing more unless the session reads working, by the rule @@ -210,17 +214,20 @@ export function cancelStructuredAgentSessionTurn( return { ok: true, value: { ...named, cancelled: withdrewAny } } } const effect = tookEffectOnceWithdrawn() - const outcome = await performCancel( - { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, - { - clientOperationId: params.envelope.clientOperationId, - ...named, - stopChild: () => context.stopAgent(params.envelope.sessionId), - withdrewQueued: withdrew - } - ) - await effect - return outcome + try { + return await performCancel( + { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, + { + clientOperationId: params.envelope.clientOperationId, + ...named, + stopChild: () => context.stopAgent(params.envelope.sessionId), + withdrewQueued: withdrew + } + ) + } finally { + // As above; `effect` never rejects, so the Stop's own error survives. + await effect + } }) }, openForWrite(context, params.envelope) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 9aed0d4acb84..672173d6c91a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -242,6 +242,14 @@ export async function createQueuedMessageTestRig() { ) } + /** The event sink the provider writes through. */ + function providerEvents(): StructuredAgentSessionEventSink { + if (!events) { + throw new Error('no provider bound') + } + return events + } + /** A host-process restart, as the queue sees it: the conversation closes, and * opens afresh under a new instance id while its rows survive. */ async function restartHostProcess(): Promise { @@ -277,6 +285,7 @@ export async function createQueuedMessageTestRig() { awaitStarted, compact, finishCompact, + providerEvents, envelope, send, stop, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 3fd04817b7a2..11f8a717b655 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -4,8 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { HOST_TEST_SESSION, + HOST_TEST_THREAD as THREAD, hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' @@ -478,4 +480,45 @@ describe('a failed Stop', () => { } await expectPaused(draftId) }) + + // The drain shares the Stop's lane, so a failed Stop must hold it until its pause lands. + it('keeps its pause when it fails while its writes wait behind owed work', async () => { + const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-w', ordinal: 900 } + const turnRow = (state: 'running' | 'completed') => { + rig + .providerEvents() + .appendItem( + turn, + { kind: 'turn', turnId: 'turn-w', state, startedAt: 1 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + const working = await rig.workingSend() + turnRow('running') + await rig.settleAccepted(working, 'w') + const draftId = await queuedDraft('paused by stop') + // The interrupt ends the turn, so the drain runs as soon as the lane frees. + vi.mocked(rig.host.deps.adapter.cancelTurn).mockImplementationOnce(async () => { + turnRow('completed') + return { cancelled: true } + }) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('no open journal') + } + const append = journal.appendItem.bind(journal) + vi.spyOn(journal, 'appendItem').mockImplementation((identity, body, options) => + body.kind === 'status' + ? Promise.reject(new Error('disk full')) + : append(identity, body, options) + ) + const owed = Promise.withResolvers() + journal['queue'].owe(() => owed.promise) + const stopping = rig.stop() + await eventually(() => expect(rig.host.deps.adapter.cancelTurn).toHaveBeenCalledOnce()) + owed.resolve() + await expect(stopping).rejects.toThrow('disk full') + expect(journal.activeTurnId()).toBeNull() + await expectPaused(draftId) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 75a30b5e8a19..b602c7981b2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -28,7 +28,7 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes * the pause, since the interrupt may have landed. A draft whose hand-off the * Stop withdrew is back to waiting in its own place, under this same pause. The * drain cannot slip a draft in between: it runs on the same serialized lane as - * the Stop. + * the Stop, which holds it until the pause lands, even when the Stop fails. */ export async function runStopWithQueuePause( ctx: AgentSessionTurnContext, From 1e9bee1167a4fda19334b9b5316a72a8e0498d13 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:42:50 -0700 Subject: [PATCH 14/27] fix(native-chat): a journal write body is typed synchronous The queue's ordering rule needs every write body to finish before it returns; serialize still took a promise-returning body, so an await inside one would let a later write land first. The body type now refuses a promise. --- .../journal-epoch-controller.ts | 7 +++--- .../journal-queued-messages.ts | 5 ++-- .../journal-row-writer.test.ts | 4 +++- .../journal-row-writer.ts | 5 ++-- .../journal-store-collaborators.ts | 3 ++- .../agent-session-journal/journal-store.ts | 2 +- .../journal-write-queue.test.ts | 24 ++++++++++++------- .../journal-write-queue.ts | 18 +++++++------- 8 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 52a5478c381a..c4e9104b0c68 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -8,6 +8,7 @@ import { publishNewEpoch } from './journal-epoch-rollover' import type { JournalLoad } from './journal-open' import type { AgentJournalEpochReason } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' +import type { JournalWriteBody } from './journal-write-queue' export class JournalEpochController { constructor( @@ -15,7 +16,7 @@ export class JournalEpochController { identity: AgentSessionJournalIdentity now: () => number mintEpoch: () => string - serialize: (run: () => Promise) => Promise + serialize: (run: JournalWriteBody) => Promise database: () => JournalHostDatabase readOnly: () => boolean setReadOnly: (readOnly: boolean) => void @@ -45,7 +46,7 @@ export class JournalEpochController { * admitted append's sequence assignment and its commit. */ roll(reason: AgentJournalEpochReason, fence: number): Promise { - return this.deps.serialize(async () => { + return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId) this.start(reason, fence) this.deps.setReadOnly(false) @@ -58,7 +59,7 @@ export class JournalEpochController { fence: number, items: readonly JournalReplacementItem[] ): Promise { - return this.deps.serialize(async () => { + return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId) assertJournalFence(fence, this.deps.highestFence()) replaceJournalEpoch({ diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 96b45c9d17cb..8ca56bea04cc 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -43,6 +43,7 @@ import { settleQueuedMessagesForRow } from './queued-message-settlement' import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' +import type { JournalWriteBody } from './journal-write-queue' /** Tombstones must outlive the window in which their operation id could still be admitted as new. */ export const QUEUED_MESSAGE_REPLAY_WINDOW_MS = @@ -51,7 +52,7 @@ export const QUEUED_MESSAGE_REPLAY_WINDOW_MS = export type JournalQueuedMessagesDeps = { sessionId: string now: () => number - serialize: (run: () => Promise) => Promise + serialize: (run: JournalWriteBody) => Promise database: () => JournalHostDatabase readOnly: () => boolean state: () => JournalReducerState @@ -227,7 +228,7 @@ export class JournalQueuedMessages { run: (db: Database.Database) => T, changed: (result: T) => boolean ): Promise { - return this.deps.serialize(async () => { + return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const { result, retired } = this.deps.database().transaction((db) => ({ result: run(db), diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts index aa0078b88a59..c0824a2e4cad 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts @@ -12,6 +12,7 @@ import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-sess import type { JournalHostDatabase } from './journal-host-database' import type { JournalRow } from './journal-row-schema' import { JournalRowWriter } from './journal-row-writer' +import { JournalWriteQueue } from './journal-write-queue' import { openTestJournalHostDatabase, readTestJournalRows, @@ -60,10 +61,11 @@ describe('journal row writer', () => { function writerHarness() { const committedRows: JournalRow[] = [] let sequence = 1 + const queue = new JournalWriteQueue(SESSION_ID) const writer = new JournalRowWriter({ sessionId: SESSION_ID, now: () => 1, - serialize: (run) => run(), + serialize: (run) => queue.serialize(run), database: () => database, readOnly: () => readOnly, highestFence: () => 0, diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index a29585580ebe..b07fda9459bc 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -4,6 +4,7 @@ import type { JournalHostDatabase } from './journal-host-database' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalRow } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' +import type { JournalWriteBody } from './journal-write-queue' /** Runs between BEGIN IMMEDIATE and COMMIT, on the SAME connection as the row * insert; a throw rolls the whole append back. Synchronous by construction so @@ -13,7 +14,7 @@ export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) export type JournalRowWriterDeps = { sessionId: string now: () => number - serialize: (run: () => Promise) => Promise + serialize: (run: JournalWriteBody) => Promise database: () => JournalHostDatabase readOnly: () => boolean highestFence: () => number @@ -36,7 +37,7 @@ export class JournalRowWriter { build: (seq: number, ts: number) => JournalRow, hook?: JournalRowTransactionHook ): Promise { - return this.deps.serialize(async () => { + return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const row = build(this.deps.nextSequence(), this.deps.now()) assertJournalFence(row.fence, this.deps.highestFence()) diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 16d3795cc6ab..b5658cc27121 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -18,6 +18,7 @@ import { JournalRowWriter } from './journal-row-writer' import { restoreJournalStore } from './journal-store-restore' import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' +import type { JournalWriteBody } from './journal-write-queue' export type JournalStoreHost = { /** Fires the journal's commit listener for a durable change that appended no @@ -29,7 +30,7 @@ export type JournalStoreHost = { legacyDirectory: string now: () => number mintEpoch: () => string - serialize: (run: () => Promise) => Promise + serialize: (run: JournalWriteBody) => Promise /** Leave a chat still in its per-chat file uncopied until its first use. */ deferPerSessionImport: boolean /** Work the chat's next write waits for. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 19baa5ed9182..943c7cd94ff2 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -192,7 +192,7 @@ export class AgentSessionJournal { * and a reader that needs rows (forward pages, catch-up) awaits it here. */ whenImported(): Promise { - return this.queue.serialize(async () => undefined) + return this.queue.serialize(() => undefined) } get importPending(): boolean { diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts index 3bee8607811b..3cdd554cc4e8 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts @@ -144,12 +144,18 @@ describe('the journal write queue', () => { it('runs a write before it returns when nothing is owed or running', () => { const queue = new JournalWriteQueue('session-1') const ran: string[] = [] - void queue.serialize(async () => { + void queue.serialize(() => { ran.push('write') }) expect(ran).toEqual(['write']) }) + it('takes only a synchronous body', async () => { + const queue = new JournalWriteQueue('session-1') + // @ts-expect-error an await inside a write would let a later write land first + await queue.serialize(async () => undefined) + }) + it('answers a write that throws with a rejection, never a throw', async () => { const queue = new JournalWriteQueue('session-1') const failed = queue.serialize(() => { @@ -157,7 +163,7 @@ describe('the journal write queue', () => { }) await expect(failed).rejects.toThrow('disk I/O error') const ran: string[] = [] - void queue.serialize(async () => { + void queue.serialize(() => { ran.push('next') }) expect(ran).toEqual(['next']) @@ -167,13 +173,13 @@ describe('the journal write queue', () => { const queue = new JournalWriteQueue('session-1') const ran: string[] = [] let nested: Promise | null = null - const outer = queue.serialize(async () => { - nested = queue.serialize(async () => { + const outer = queue.serialize(() => { + nested = queue.serialize(() => { ran.push('nested') }) ran.push('outer') }) - const after = queue.serialize(async () => { + const after = queue.serialize(() => { ran.push('after') }) expect(ran).toEqual(['outer']) @@ -189,12 +195,12 @@ describe('the journal write queue', () => { await owed.promise ran.push('owed') }) - const first = queue.serialize(async () => { + const first = queue.serialize(() => { ran.push('first') }) const read = queue.readInOrder(() => ran.push('read')) owed.resolve() - const second = queue.serialize(async () => { + const second = queue.serialize(() => { ran.push('second') }) await Promise.all([first, read, second]) @@ -219,7 +225,7 @@ describe('a read in the journal write queue', () => { }) void queue.readInOrder(() => ran.push('read before')) expect(ran).toEqual(['read before']) - const write = queue.serialize(async () => { + const write = queue.serialize(() => { ran.push('write') }) const read = queue.readInOrder(() => ran.push('read after')) @@ -230,7 +236,7 @@ describe('a read in the journal write queue', () => { it('runs behind a write that failed', async () => { const queue = new JournalWriteQueue('session-1') queue.owe(async () => undefined) - const failed = queue.serialize(async () => { + const failed = queue.serialize(() => { throw new Error('disk I/O error') }) const read = queue.readInOrder(() => 'read') diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index b91e06870c7a..c80c136e0342 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -6,14 +6,17 @@ import { AgentSessionJournalError } from './journal-write-guards' +/** A write body. Synchronous: an await inside one would let a later write land first. */ +export type JournalWriteBody = () => T extends PromiseLike ? never : T + /** * A write has landed in the fold when its call returns, except during an owed import, when it * lands in queue order. * * A write finds the queue idle unless work is owed, a write is running, or writes wait in line; - * then it runs before `serialize` returns. Every write body is synchronous (a transaction refuses - * to await), so it has committed by then. Otherwise it joins the line: behind the owed import it - * pays first, or behind the running write it was issued from, never nested inside it. + * then it runs before `serialize` returns. Every write body is synchronous (`JournalWriteBody` + * refuses a promise), so it has committed by then. Otherwise it joins the line: behind the owed + * import it pays first, or behind the running write it was issued from, never nested inside it. * Admission is checked at ENQUEUE and is permanent. */ export class JournalWriteQueue { @@ -32,7 +35,7 @@ export class JournalWriteQueue { this.closed = true } - serialize(run: () => Promise): Promise { + serialize(run: JournalWriteBody): Promise { if (this.closed) { return Promise.reject(this.closedError()) } @@ -48,8 +51,7 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } - const step = async (): Promise => read() - return this.lineBusy ? this.join(step, false) : this.runNow(step) + return this.lineBusy ? this.join(read, false) : this.runNow(read) } private get lineBusy(): boolean { @@ -85,7 +87,7 @@ export class JournalWriteQueue { } /** Runs before returning; a write issued from inside it joins the line behind it. */ - private runNow(run: () => Promise): Promise { + private runNow(run: () => T): Promise { let release: (settled: Promise) => void = () => undefined this.writes = new Promise((resolve) => { release = resolve @@ -103,7 +105,7 @@ export class JournalWriteQueue { return result } - private join(run: () => Promise, paysOwed: boolean): Promise { + private join(run: () => T, paysOwed: boolean): Promise { const started = paysOwed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run) this.writes = started.catch(() => undefined) this.waiting++ From 9dbfb39dbaa39f37f2b7815f59e708552df2f4af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:45:42 -0700 Subject: [PATCH 15/27] fix(native-chat): a stream's first window snapshot always lands The first-delta write counted as the growth checkpoint, so the window's snapshot was skipped until 32 more characters arrived: a stream showed only its first token, and a Codex reasoning row could sit as an empty aside. The coalescer now marks the row-creating emit and the first snapshot after it, and both providers' growth throttles write those. --- .../claude-streamed-text-checkpoints.test.ts | 16 ++++++++++ .../claude-streamed-text-checkpoints.ts | 2 +- .../codex/codex-structured-item-streams.ts | 4 +-- ...ctured-journal-translation-streams.test.ts | 22 ++++++++++++++ .../agent-session-delta-coalescer.test.ts | 17 +++++++++++ .../agent-session-delta-coalescer.ts | 29 ++++++++++++------- 6 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/main/claude/claude-streamed-text-checkpoints.test.ts b/src/main/claude/claude-streamed-text-checkpoints.test.ts index 7f8c1c150176..47fa2d46714a 100644 --- a/src/main/claude/claude-streamed-text-checkpoints.test.ts +++ b/src/main/claude/claude-streamed-text-checkpoints.test.ts @@ -102,6 +102,22 @@ describe('claude streamed text checkpoints', () => { expect(store.pending).toBe(1) }) + it("writes the window's first snapshot however little it grew the row, then widens", () => { + const { store, rows, runWindow } = checkpoints() + + store.append(identityOf('block-1'), 'I') + store.append(identityOf('block-1'), "'ll check") + runWindow() + // The row-creating write is not a checkpoint: the window's snapshot lands in full. + expect(rows).toEqual([ + { uuid: 'block-1', text: 'I' }, + { uuid: 'block-1', text: "I'll check" } + ]) + store.append(identityOf('block-1'), ' the tests.') + runWindow() + expect(rows).toHaveLength(2) + }) + it('drops every block still awaiting its final frame at settlement', () => { const { store, rows, runWindow } = checkpoints() diff --git a/src/main/claude/claude-streamed-text-checkpoints.ts b/src/main/claude/claude-streamed-text-checkpoints.ts index ad21ee790a60..364e3aa01bb4 100644 --- a/src/main/claude/claude-streamed-text-checkpoints.ts +++ b/src/main/claude/claude-streamed-text-checkpoints.ts @@ -112,7 +112,7 @@ export function createClaudeStreamedTextCheckpoints( const coalescer = createAgentSessionDeltaCoalescer({ ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), - emit: (key, text) => persist(key, text, false) + emit: (key, text, _snapshot, opening) => persist(key, text, opening) }) const drop = (key: string): void => { diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index 33b3fa7b3cb9..148cf9caa356 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -142,8 +142,8 @@ export function createCodexStructuredItemStreams( maxTotalRetainedBytes: deps.maxTotalRetainedBytes, isProtected: (key) => states.isPersistent(key), schedule: deps.schedule, - emit: (key, text) => { - return persist(key, text, false) + emit: (key, text, _snapshot, opening) => { + return persist(key, text, opening) } }) diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 83c917de732f..32b29f35ddfe 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -563,6 +563,28 @@ describe('codex journal translation', () => { { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'begun' }] } ]) }) + + // The row-creating write is not a checkpoint, so a short headline is not held back behind it. + it("writes a reasoning row's first window in full after the section break that made it", () => { + const { translator, tap, window } = translatorWith() + translator.handle(TURN_STARTED) + const reasoning = { turnId: TURN_ID, itemId: 'reasoning-1', summaryIndex: 0 } + translator.handle(notification('item/reasoning/summaryPartAdded', reasoning)) + for (const delta of ['**Inspecting', ' tests**']) { + translator.handle(notification('item/reasoning/summaryTextDelta', { ...reasoning, delta })) + } + window.fire() + + expect(tap.rows.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text: '\n' }] }, + { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: '\n**Inspecting tests**' }] + } + ]) + translator.dispose() + }) }) describe('notice journal pipeline', () => { diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts index f6d710e2d53a..44b07e411251 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts @@ -66,6 +66,23 @@ describe('agent-session delta coalescer', () => { ]) }) + // A growth throttle behind emit must not count the row-creating write as a checkpoint. + it('marks the row-creating emit and the first snapshot after it as the opening', () => { + const clock = manualClock() + const openings: boolean[] = [] + const instance = createAgentSessionDeltaCoalescer({ + emit: (_key, _text, _snapshot, opening) => openings.push(opening), + schedule: clock.schedule + }) + + for (const delta of ['I', "'ll", ' check']) { + instance.append('item-1', delta) + clock.fire() + } + + expect(openings).toEqual([true, true, false]) + }) + it('emits the accumulated snapshot again, not the increment, on the next window', () => { const clock = manualClock() const { instance, emitted } = coalescer(clock) diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index ada37bcd8246..6a328472b354 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -33,8 +33,14 @@ export type AgentSessionDeltaSnapshot = { } export type AgentSessionDeltaCoalescerDeps = { - /** Called with the FULL text accumulated for the key, not the increment. */ - emit: (key: string, text: string, snapshot: AgentSessionDeltaSnapshot) => unknown + /** Called with the FULL text accumulated for the key, not the increment. `opening` marks the + * row-creating emit and the first snapshot after it, which a growth throttle must not skip. */ + emit: ( + key: string, + text: string, + snapshot: AgentSessionDeltaSnapshot, + opening: boolean + ) => unknown windowMs?: number maxRetainedBytes?: number maxTotalRetainedBytes?: number @@ -84,8 +90,8 @@ export function createAgentSessionDeltaCoalescer( observedBytes: number truncated: boolean dirty: boolean - /** An emit has succeeded, so the row exists and later text only revises it. */ - emitted: boolean + /** Successful emits, counted to 2: the first makes the row, the second is its first snapshot. */ + emits: number } >() let totalRetainedBytes = 0 @@ -98,16 +104,17 @@ export function createAgentSessionDeltaCoalescer( return true } const text = stream.chunks.join('') - const emitted = deps.emit(key, text, { + const emitted = deps.emit( + key, text, - observedBytes: stream.observedBytes, - truncated: stream.truncated - }) + { text, observedBytes: stream.observedBytes, truncated: stream.truncated }, + stream.emits < 2 + ) if (emitted === false) { return false } stream.dirty = false - stream.emitted = true + stream.emits = Math.min(2, stream.emits + 1) return true } @@ -164,7 +171,7 @@ export function createAgentSessionDeltaCoalescer( observedBytes: 0, truncated: false, dirty: false, - emitted: false + emits: 0 } if (!deps.isProtected?.(key)) { evictable.add(key) @@ -192,7 +199,7 @@ export function createAgentSessionDeltaCoalescer( } streams.set(key, stream) // The first text makes the row; refused under backpressure, it waits for the window. - if (!stream.emitted && stream.dirty && stream.retainedBytes > 0 && flushKey(key)) { + if (stream.emits === 0 && stream.dirty && stream.retainedBytes > 0 && flushKey(key)) { return true } // One timer for every stream: a shared deadline bounds latency the same From 6931820076147c0e0ae253771fe61e2d607dd903 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:46:23 -0700 Subject: [PATCH 16/27] test(native-chat): streamed text rewritten in place above a Stop note Covers a stream whose later deltas wait in the window across a Stop, for Codex and Claude, and a Codex item completed inside the window. --- ...-agent-session-streamed-text-order.test.ts | 90 +++++++++++++++---- 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts index 0103973bdd33..5bf90efc372a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts @@ -30,6 +30,11 @@ beforeEach(() => { }) const STREAMED = 'Looking at the tests first.' +const LATER_DELTAS = [ + 'ing at the failing tests first,', + ' then the fix that should make them pass.' +] +const LONGER = `Look${LATER_DELTAS.join('')}` type Stream = { openTurn: () => void; streamText: (text: string) => void } @@ -54,7 +59,10 @@ function heldWindow() { } } -function codexStream(sink: StructuredAgentSessionEventSink, schedule: Schedule): Stream { +function codexStream( + sink: StructuredAgentSessionEventSink, + schedule: Schedule +): Stream & { completeText: (text: string) => void } { const translator = createCodexJournalTranslator({ sink, sessionId: SESSION, @@ -74,7 +82,9 @@ function codexStream(sink: StructuredAgentSessionEventSink, schedule: Schedule): notify('turn/started', {}) notify('item/started', { item: { type: 'agentMessage', id: 'reply', text: '' } }) }, - streamText: (text) => notify('item/agentMessage/delta', { itemId: 'reply', delta: text }) + streamText: (text) => notify('item/agentMessage/delta', { itemId: 'reply', delta: text }), + completeText: (text) => + notify('item/completed', { item: { type: 'agentMessage', id: 'reply', text } }) } } @@ -123,31 +133,73 @@ function describeRow(body: AgentJournalItemBody): string { return body.kind } +async function streaming( + stream: (sink: StructuredAgentSessionEventSink, schedule: Schedule) => TStream +) { + await attach() + const window = heldWindow() + const provider = stream(acquire.mock.calls.at(-1)![0].events!, window.schedule) + provider.openTurn() + const journal: AgentSessionJournal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + return { window, provider, journal } +} + +async function stopTurn(journal: AgentSessionJournal): Promise { + const turnId = journal.activeTurnId() + expect(turnId).not.toBeNull() + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId }), + turnId: turnId! + }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) +} + +function rowsOf(journal: AgentSessionJournal): string[] { + return journal + .snapshot() + .items.map((item) => describeRow(item.body)) + .filter((row) => row !== 'turn') +} + describe.each([ ['Codex', codexStream], ['Claude', claudeStream] ])('%s text streamed inside the coalescing window', (_provider, stream) => { it('lands ahead of the note of a Stop issued in that window', async () => { - await attach() - const window = heldWindow() - const provider = stream(acquire.mock.calls.at(-1)![0].events!, window.schedule) - provider.openTurn() + const { window, provider, journal } = await streaming(stream) provider.streamText(STREAMED) - const journal: AgentSessionJournal = host.collaboratorsForTests().sessions.get(SESSION)!.journal - const turnId = journal.activeTurnId() - expect(turnId).not.toBeNull() - const stopped = await host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId }), - turnId: turnId! - }) - expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + await stopTurn(journal) + window.close() + + expect(rowsOf(journal)).toEqual([`text: ${STREAMED}`, 'note: Cancellation requested.']) + }) + + it('is rewritten in place, above the note, when the window closes after the Stop', async () => { + const { window, provider, journal } = await streaming(stream) + provider.streamText('Look') + for (const delta of LATER_DELTAS) { + provider.streamText(delta) + } + expect(rowsOf(journal)).toEqual(['text: Look']) + + await stopTurn(journal) + window.close() + + expect(rowsOf(journal)).toEqual([`text: ${LONGER}`, 'note: Cancellation requested.']) + }) +}) + +describe('Codex text completed inside the coalescing window', () => { + it('lands its final body on the row its first delta made, above a later Stop note', async () => { + const { window, provider, journal } = await streaming(codexStream) + provider.streamText('Look') + provider.streamText('ing at the failing') + provider.completeText(LONGER) + + await stopTurn(journal) window.close() - const rows = journal - .snapshot() - .items.map((item) => describeRow(item.body)) - .filter((row) => row !== 'turn') - expect(rows).toEqual([`text: ${STREAMED}`, 'note: Cancellation requested.']) + expect(rowsOf(journal)).toEqual([`text: ${LONGER}`, 'note: Cancellation requested.']) }) }) From 6e6dc656d8a2553ca0602fffdf97a3e2d84d67ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:48:19 -0700 Subject: [PATCH 17/27] chore(native-chat): drop comments that still describe coalesced journal writes --- src/main/claude/claude-open-turn.ts | 2 +- src/main/claude/claude-subagent-group-row.ts | 2 -- src/main/claude/claude-subagent-roster.test.ts | 3 +-- .../codex/codex-structured-journal-translation-turns.ts | 2 +- src/main/codex/codex-subagent-roster.test.ts | 6 +++--- .../structured-agent-session-event-sink-queue.ts | 2 +- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 864624fe59e0..da8725bfe2cb 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -195,7 +195,7 @@ export class ClaudeOpenTurn { { lifecycle: item.body, ...(contextUsage ? { contextUsage } : {}) }, { publish: false, options: item.options } ) - // Preserve first-work evidence when completion arrives before the journal drains. + // Keyed apart, so this never replaces the start's publication while it still waits to run. this.deps.sink.publish({ coalescingKey: item.publishCoalescingKey }) } } diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts index e502b074d20a..051970a5b36d 100644 --- a/src/main/claude/claude-subagent-group-row.ts +++ b/src/main/claude/claude-subagent-group-row.ts @@ -110,7 +110,5 @@ export function writeClaudeSubagentGroupRow( } group.lastSerialized = serialized sink.appendItem(group.identity, body, { turnScope: group.turnScope }) - // Publish keeps the sink's own coalescing slot: sharing the row's key makes - // each queued publish evict the append it was meant to flush. sink.publish() } diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index 20587753ece2..6c60884df444 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -478,8 +478,7 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => { currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) - // The first append is in flight while the rest are submitted, so a publish - // sharing the row's coalescing key would evict them. + // The first append is in flight while the rest are submitted; each must still land. roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' })) roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' })) roster.observeSystemFrame( diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 349233a32d1d..33f138c6578d 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -123,7 +123,7 @@ export function publishCodexTurnLifecycle(input: { } else { input.sink.appendItem(identity, body, appendOptions) } - // Preserve first-work evidence when completion arrives before the journal drains. + // Keyed apart, so a completion's publication never replaces a start one still waiting to run. const publishOptions = { lifecycle: true, ...(input.state === 'running' diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index e310d78a5158..d7c2ba0451cd 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -147,7 +147,7 @@ function createQueuedHarness(): { } describe('CodexSubagentRoster', () => { - it('does not let its own publish evict the still-queued roster append', () => { + it('lands the roster append its own publish was buffered beside', () => { const { roster, appended, drain } = createQueuedHarness() deliver( @@ -696,8 +696,8 @@ describe('CodexSubagentRoster', () => { accepted: true }) // The retry re-appends when the publish was the half that failed; the real - // queue coalesces those two by the group key into one journal write. What - // must not happen is the revision never being published at all. + // sink writes both, as revisions of the one group row. What must not happen + // is the revision never being published at all. expect(published).toHaveLength(1) const body = appended.at(-1)?.body expect( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index ffe328504722..1147fe043195 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -287,7 +287,7 @@ export class StructuredAgentSessionSinkQueue { } } - /** Writes already handed over still land; nothing submitted after this is admitted. */ + /** Writes handed over before this runs still land; nothing submitted after it is admitted. */ private fail = (error: unknown): void => { if (this.failure === null) { this.failure = { error } From 047f018a8c4a6fa04a1815764ad812b2d1740027 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:50:16 -0700 Subject: [PATCH 18/27] fix(native-chat): type the draft-table write body synchronous too --- .../agent-session-journal/journal-queued-messages.ts | 4 ++-- .../agent-session-journal/journal-write-queue.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 8ca56bea04cc..acc357a3e379 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -43,7 +43,7 @@ import { settleQueuedMessagesForRow } from './queued-message-settlement' import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' -import type { JournalWriteBody } from './journal-write-queue' +import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue' /** Tombstones must outlive the window in which their operation id could still be admitted as new. */ export const QUEUED_MESSAGE_REPLAY_WINDOW_MS = @@ -225,7 +225,7 @@ export class JournalQueuedMessages { /** One standalone draft-table transaction on the journal's queue; one that * changed rows bumps the revision and notifies after COMMIT. */ private transact( - run: (db: Database.Database) => T, + run: (db: Database.Database) => JournalWriteResult, changed: (result: T) => boolean ): Promise { return this.deps.serialize(() => { diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index c80c136e0342..74ef33472bd4 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -6,8 +6,10 @@ import { AgentSessionJournalError } from './journal-write-guards' -/** A write body. Synchronous: an await inside one would let a later write land first. */ -export type JournalWriteBody = () => T extends PromiseLike ? never : T +/** What a write body returns: never a promise, since an await inside one would let a later write + * land first. */ +export type JournalWriteResult = T extends PromiseLike ? never : T +export type JournalWriteBody = () => JournalWriteResult /** * A write has landed in the fold when its call returns, except during an owed import, when it From 8bf54453e47a220aa8faa8525847ad77c462a979 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:08:57 -0700 Subject: [PATCH 19/27] fix(native-chat): an empty delta owes no streamed-text emit The opening snapshot is forced past the growth rule, so an empty second Codex delta rewrote the row with identical text. The coalescer now marks a stream dirty only when a delta adds text. --- ...ructured-journal-translation-streams.test.ts | 17 +++++++++++++++++ .../agent-session-delta-coalescer.test.ts | 11 +++++++++++ .../agent-session-delta-coalescer.ts | 5 ++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 32b29f35ddfe..18c4b8d17441 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -564,6 +564,23 @@ describe('codex journal translation', () => { ]) }) + it('writes no second revision for an empty delta after the first', () => { + const { translator, tap, window } = translatorWith() + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + for (const delta of ['Hello there', '']) { + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta })) + } + window.fire() + + expect(tap.rows.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Hello there' }] } + ]) + translator.dispose() + }) + // The row-creating write is not a checkpoint, so a short headline is not held back behind it. it("writes a reasoning row's first window in full after the section break that made it", () => { const { translator, tap, window } = translatorWith() diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts index 44b07e411251..0c22a7434e11 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts @@ -124,6 +124,17 @@ describe('agent-session delta coalescer', () => { expect(emitted).toEqual([['item-1', 'hi']]) }) + it('owes no emit for an empty delta, even as the forced first snapshot', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'Hello there') + instance.append('item-1', '') + clock.fire() + + expect(emitted).toEqual([['item-1', 'Hello there']]) + }) + it('flushes pending text ahead of a lifecycle event and cancels the window', () => { const clock = manualClock() const { instance, emitted } = coalescer(clock) diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index 6a328472b354..027b31e6b9a8 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -195,7 +195,10 @@ export function createAgentSessionDeltaCoalescer( stream.chunks = next.chunks stream.retainedBytes = next.retainedBytes stream.truncated = next.truncated - stream.dirty = true + // An empty delta changes no text, so it owes no emit. + if (deltaBytes > 0) { + stream.dirty = true + } } streams.set(key, stream) // The first text makes the row; refused under backpressure, it waits for the window. From 64da79eb5e242a99e77b1dab5bb4265c6bbd5263 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:13:58 -0700 Subject: [PATCH 20/27] fix(native-chat): a mutation's open pays an owed import first With the flushes gone, a Stop naming no turn, a goal set or a /clear could read the fold while provider rows still waited behind a restore's owed import: the Stop answered cancelled:false and interrupted nothing. The open every mutation shares now waits for the import, as a reader's does; a failed import is reported and never refuses the mutation. --- ...structured-agent-session-host-mutations.ts | 2 +- ...agent-session-mutation-owed-import.test.ts | 148 ++++++++++++++++++ ...ed-agent-session-queued-pause-lift.test.ts | 7 +- ...ructured-agent-session-send-preparation.ts | 20 ++- .../structured-agent-session-thread-goal.ts | 3 +- .../structured-agent-session-turns-cancel.ts | 4 +- 6 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index f4d9ee29a814..e62c47f6eddd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -177,7 +177,7 @@ export function cancelStructuredAgentSessionTurn( run: (ctx) => runStopWithQueuePause(ctx, async (tookEffect) => { // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. - // Issued, not awaited: the interrupt never waits on bookkeeping, nor on an owed import. + // Issued, not awaited: the interrupt never waits on bookkeeping. (The open paid any owed import.) const withdrew = withdrawQueuedForStop(ctx, () => ctx.journal.rejectQueuedSubmissions( ctx.fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts new file mode 100644 index 000000000000..df13fa970a70 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts @@ -0,0 +1,148 @@ +// A mutation reads the fold after every write issued before it, even while a restore's import is +// owed: the open pays the import first, so provider rows queued behind it have landed. A failed +// import is reported and never refuses the mutation. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' + +let host: StructuredAgentSessionHost +let acquire: Mock +let cancelTurn: Mock +let warned: ReturnType + +beforeEach(() => { + ;({ host, acquire, cancelTurn } = hostTestState()) + warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function journal(): AgentSessionJournal { + const open = host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!open) { + throw new Error('no open journal') + } + return open +} + +function providerEvents(): StructuredAgentSessionEventSink { + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no provider bound') + } + return events +} + +/** Owed work standing in for a restore's import: every write waits behind it until it settles. */ +function oweImport(): PromiseWithResolvers { + const owed = Promise.withResolvers() + journal()['queue'].owe(() => owed.promise) + return owed +} + +/** A turn the provider opened by itself, its row queued behind the owed import. */ +function providerOpensTurn(turnId: string, ordinal: number): void { + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId, ordinal }, + { kind: 'turn', turnId, state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function stopNamingNoTurn() { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) +} + +describe('a mutation while an import is owed', () => { + it('a Stop naming no turn sees the turn the provider opened, and interrupts it', async () => { + await attach() + const owed = oweImport() + providerOpensTurn('turn-p', 900) + expect(journal().activeTurnId()).toBeNull() + + const stopping = stopNamingNoTurn() + await new Promise((resolve) => setTimeout(resolve, 50)) + // It waits for the import, as a reader does, then reads the turn. + expect(cancelTurn).not.toHaveBeenCalled() + owed.resolve() + + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(cancelTurn).toHaveBeenCalledOnce() + }) + + it('a goal set sees the goal and the turn the provider wrote, and replaces it in that turn', async () => { + await attach() + const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + Object.assign(host.deps.adapter, { changeThreadGoal, supportsThreadGoal: () => true }) + const owed = oweImport() + providerOpensTurn('turn-g', 901) + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-g', ordinal: 902 }, + { + kind: 'status', + text: 'Goal', + threadGoal: { + state: 'set', + goal: { + objective: 'Earlier goal', + status: 'active', + tokenBudget: null, + tokensUsed: 1, + timeUsedSeconds: 2, + createdAt: 3_000, + updatedAt: 4_000 + } + } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + const change = { kind: 'set' as const, objective: 'New goal' } + const setting = host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + // Long enough for the goal set to reach its read before the import lands. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(changeThreadGoal).not.toHaveBeenCalled() + owed.resolve() + + expect(await setting).toMatchObject({ ok: true }) + expect(changeThreadGoal).toHaveBeenCalledWith(expect.objectContaining({ replacesGoal: true })) + const objective = journal() + .snapshot() + .items.find((item) => item.body.kind === 'message' && item.body.sentAs === 'goal') + expect(objective?.turnScope).toEqual({ + kind: 'turn', + turnItemId: expect.stringContaining(':turn-g:') + }) + }) + + it('a failed import is reported and the Stop still answers', async () => { + await attach() + const owed = oweImport() + owed.reject(new Error('disk I/O error')) + + expect(await stopNamingNoTurn()).toMatchObject({ ok: true }) + expect(warned).toHaveBeenCalledWith( + '[agent-session] the import owed before a write failed:', + expect.objectContaining({ message: 'disk I/O error' }) + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 11f8a717b655..04375cfa3160 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -512,8 +512,13 @@ describe('a failed Stop', () => { ? Promise.reject(new Error('disk full')) : append(identity, body, options) ) + // Owed from the Stop's first write, after the open paid any import: its writes wait behind it. const owed = Promise.withResolvers() - journal['queue'].owe(() => owed.promise) + const withdraw = journal.rejectQueuedSubmissions.bind(journal) + vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { + journal['queue'].owe(() => owed.promise) + return withdraw(...args) + }) const stopping = rig.stop() await eventually(() => expect(rig.host.deps.adapter.cancelTurn).toHaveBeenCalledOnce()) owed.resolve() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index be29089991bd..b824d3c68a30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -53,20 +53,30 @@ export function structuredAgentSessionSendBlock( return null } -/** The conversation a send or a Stop writes to, opened when this host holds it closed. */ +/** The conversation a send or a Stop writes to, opened when this host holds it closed. Once it + * answers, every write issued before it has settled, so a mutation reads a whole fold. */ export async function openConversationForWrite( openConversation: (sessionId: string) => Promise, envelope: AgentSessionMutationEnvelope ): Promise { + let session: StructuredAgentSessionHostSession | null try { - if (await openConversation(envelope.sessionId)) { - return { ok: true } - } - return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } + session = await openConversation(envelope.sessionId) } catch (error) { console.warn('[agent-session] opening the conversation for a write failed:', error) return { ok: false, refusal: journalOpenRefusal(error) } } + if (!session) { + return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } + } + // Writes wait behind a restore's owed import. A failed import settled them too (they failed + // with it), so it is reported and never refuses the mutation: its own writes fail as they would. + if (session.journal.importPending) { + await session.journal.whenImported().catch((error: unknown) => { + console.warn('[agent-session] the import owed before a write failed:', error) + }) + } + return { ok: true } } /** The conversation a write lands in, opened when this host holds it closed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index b06aa0e48f88..f1612e03db2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -55,7 +55,8 @@ export async function performThreadGoalChange( let replacesGoal = false if (change.kind === 'set') { // Read before the objective row lands: that row is a message, not a goal transition. A goal - // transition the host accepted is already in the fold: it landed at its call. + // transition the host accepted is already in the fold: it landed at its call, or before the + // open's owed import finished. replacesGoal = ctx.journal.threadGoal() !== null } // Journal first: an active goal starts provider work at once, and the objective diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index b1904070578f..8093d2a54368 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -13,8 +13,8 @@ import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -/** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads - * it without waiting on the write queue. */ +/** Whether the fold reads working. Every write has landed by its call's return, and the open paid + * any owed import, so a Stop reads it without waiting on the write queue. */ export function isMainAgentWorking( ctx: Pick ): boolean { From fdce158d06b71ae94ba95de3927aade6390a44e7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:27:16 -0700 Subject: [PATCH 21/27] chore(native-chat): whenImported says mutations await it too --- src/main/native-chat/agent-session-journal/journal-store.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 943c7cd94ff2..104a02ed2b70 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -188,8 +188,9 @@ export class AgentSessionJournal { /** * Resolves once the chat's rows are in the host's database. A restore's open serves a chat still - * in its per-chat file from a read-only fold of it; the copy runs before the chat's first write, - * and a reader that needs rows (forward pages, catch-up) awaits it here. + * in its per-chat file from a read-only fold of it; the copy runs before the chat's first write. + * A reader that needs rows (forward pages, catch-up) and every mutation's open await it here, so + * each reads the fold after every earlier write. */ whenImported(): Promise { return this.queue.serialize(() => undefined) From ac33cb817797faf55dd2035e351dbf4b57afb99e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:33:26 -0700 Subject: [PATCH 22/27] test(native-chat): a Stop interrupts before its withdrawal or pause settles Pins the order for a write that is held and then fails, for a Stop naming its turn and one naming none. --- ...red-agent-session-stop-never-waits.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index 6e184aef520d..ad913eca5b20 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -88,6 +88,37 @@ describe.each([ ) }) + // Held, then failing: the interrupt goes out while the write is still pending, not after it settles. + it.each([ + ['withdrawing the queued sends', "Stop's withdrawal skipped:"], + ['recording its queue pause', "Stop's queue pause skipped:"] + ] as const)('interrupts before %s settles, then reports its failure', async (step, warning) => { + const journal = await runningTurn() + const order: string[] = [] + const held = Promise.withResolvers() + if (step === 'withdrawing the queued sends') { + vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(() => held.promise) + } else { + vi.spyOn(journal.queuedMessages, 'recordPause').mockImplementation(() => held.promise) + } + cancelTurn.mockImplementation(async () => { + order.push('interrupt') + return { cancelled: true } + }) + + const stopping = stop(fields) + await new Promise((resolve) => setTimeout(resolve, 50)) + order.push('write fails') + held.reject(new Error(MALFORMED)) + + expect(await stopping).toMatchObject({ ok: true }) + expect(order).toEqual(['interrupt', 'write fails']) + expect(warned).toHaveBeenCalledWith( + `[agent-session] ${warning}`, + expect.objectContaining({ error: MALFORMED }) + ) + }) + // The open pays an import owed before the Stop, so the work falls owed at the Stop's first write: // the one moment the Stop's own writes can wait behind it. it('interrupts before owed work its writes wait behind is paid', async () => { From 6a066aa57ccee2b85b0327f673b7ac9291829951 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:37:10 -0700 Subject: [PATCH 23/27] test(native-chat): a Stop ends a starting child before its withdrawal or pause settles --- ...red-agent-session-stop-never-waits.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index ad913eca5b20..635532c3db33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -50,6 +50,24 @@ async function runningTurn(): Promise { return journal } +/** A child the adapter published before proving its startup, so a Stop ends the start. */ +async function startingChild(): Promise<{ journal: AgentSessionJournal; closeSession: Mock }> { + const acquired = acquire.getMockImplementation()! + acquire.mockImplementationOnce(async (input) => ({ + ...(await acquired(input)), + providerChildPhase: 'starting' as const + })) + const closeSession = vi.fn(async () => true) + Object.assign(host.deps.adapter, { closeSession }) + await attach() + const session = host.collaboratorsForTests().sessions.get(SESSION) + if (!session) { + throw new Error('no open session') + } + expect(session.child?.phase).toBe('starting') + return { journal: session.journal, closeSession } +} + function stop(fields: { turnId?: string }) { return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) } @@ -119,6 +137,39 @@ describe.each([ ) }) + it.each([ + ['withdrawing the queued sends', "Stop's withdrawal skipped:"], + ['recording its queue pause', "Stop's queue pause skipped:"] + ] as const)( + 'ends a starting child before %s settles, then reports its failure', + async (step, warning) => { + const { journal, closeSession } = await startingChild() + const order: string[] = [] + const held = Promise.withResolvers() + if (step === 'withdrawing the queued sends') { + vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(() => held.promise) + } else { + vi.spyOn(journal.queuedMessages, 'recordPause').mockImplementation(() => held.promise) + } + closeSession.mockImplementation(async () => { + order.push('stop') + return true + }) + + const stopping = stop(fields) + await new Promise((resolve) => setTimeout(resolve, 50)) + order.push('write fails') + held.reject(new Error(MALFORMED)) + + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(order).toEqual(['stop', 'write fails']) + expect(warned).toHaveBeenCalledWith( + `[agent-session] ${warning}`, + expect.objectContaining({ error: MALFORMED }) + ) + } + ) + // The open pays an import owed before the Stop, so the work falls owed at the Stop's first write: // the one moment the Stop's own writes can wait behind it. it('interrupts before owed work its writes wait behind is paid', async () => { From 69852748f5ec0ff613b754eb58cce12e1e3f26c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:41:36 -0700 Subject: [PATCH 24/27] test(native-chat): Stop order tests wait for the interrupt, not a 50 ms timer --- ...red-agent-session-stop-never-waits.test.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index 635532c3db33..b07d3d5fbde1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -73,6 +73,8 @@ function stop(fields: { turnId?: string }) { } const MALFORMED = 'database disk image is malformed' +/** The held write fails only once the interrupt went out; a Stop that awaited it times out here. */ +const INTERRUPT_WAIT = { timeout: 5_000 } describe.each([ ['naming no turn', {}], @@ -125,9 +127,12 @@ describe.each([ }) const stopping = stop(fields) - await new Promise((resolve) => setTimeout(resolve, 50)) - order.push('write fails') - held.reject(new Error(MALFORMED)) + try { + await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce(), INTERRUPT_WAIT) + } finally { + order.push('write fails') + held.reject(new Error(MALFORMED)) + } expect(await stopping).toMatchObject({ ok: true }) expect(order).toEqual(['interrupt', 'write fails']) @@ -157,9 +162,12 @@ describe.each([ }) const stopping = stop(fields) - await new Promise((resolve) => setTimeout(resolve, 50)) - order.push('write fails') - held.reject(new Error(MALFORMED)) + try { + await vi.waitFor(() => expect(closeSession).toHaveBeenCalledOnce(), INTERRUPT_WAIT) + } finally { + order.push('write fails') + held.reject(new Error(MALFORMED)) + } expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) expect(order).toEqual(['stop', 'write fails']) From a80d698b0fbff01d493ba8761ac94245a7d87af8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:52:25 -0700 Subject: [PATCH 25/27] test(native-chat): settlement-order test reads rows through the journal row parser Replaces a Reflect.get field walk, which the low-evidence audit rejects, with parseJournalRow and the named row types. --- ...ent-session-codex-settlement-order.test.ts | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts index 6de6683938b6..71405b1b1222 100644 --- a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -14,6 +14,10 @@ import type { } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { + AgentJournalDispatchState, + AgentJournalTurnItem +} from '../../shared/agent-session-journal-types' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -25,6 +29,11 @@ import { liveTestJournalRows, openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + parseJournalRow, + type JournalLifecycleMutation, + type JournalRow +} from '../native-chat/agent-session-journal/journal-row-schema' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -73,37 +82,32 @@ async function send(text: string): Promise { return sent.value.clientMessageId } -/** One field of a stored row's JSON, read without trusting its shape. */ -function field(value: unknown, ...path: string[]): unknown { - let current = value - for (const key of path) { - current = - typeof current === 'object' && current !== null ? Reflect.get(current, key) : undefined - } - return current -} +/** A stored row, or one mutation of a lifecycle batch: what a settlement or a frame lands as. */ +type JournalEntry = JournalRow | JournalLifecycleMutation /** The sequence of the first row that `matches`, or of the lifecycle batch carrying it. */ -function seqOf(matches: (row: unknown) => boolean): number | undefined { +function seqOf(matches: (entry: JournalEntry) => boolean): number | undefined { return liveTestJournalRows(openTestJournalHostDatabase(root).db, SESSION).find((stored) => { - const row: unknown = JSON.parse(stored.rowJson) - const mutations = field(row, 'mutations') + const parsed = parseJournalRow(stored.rowJson) + if (!parsed.ok) { + return false + } + const { row } = parsed return ( matches(row) || - (field(row, 'kind') === 'lifecycle-batch' && - Array.isArray(mutations) && - mutations.some((mutation) => matches(mutation))) + (row.kind === 'lifecycle-batch' && row.mutations.some((mutation) => matches(mutation))) ) })?.seq } -const dispatchRow = (clientMessageId: string, state: string) => (row: unknown) => - field(row, 'kind') === 'dispatch' && - field(row, 'clientMessageId') === clientMessageId && - field(row, 'state') === state +const dispatchRow = + (clientMessageId: string, state: AgentJournalDispatchState) => (entry: JournalEntry) => + entry.kind === 'dispatch' && entry.clientMessageId === clientMessageId && entry.state === state -const turnRow = (row: unknown): boolean => - field(row, 'kind') === 'item' && field(row, 'body', 'kind') === 'turn' +/** The turn lifecycle an item row or mutation carries, if it carries one. */ +function turnOf(entry: JournalEntry): AgentJournalTurnItem | null { + return entry.kind === 'item' && entry.body.kind === 'turn' ? entry.body : null +} /** An assistant reply Codex finishes in the same read as the frame under test. */ function streamedReplyAhead(): void { @@ -185,7 +189,10 @@ describe('a settlement Codex proves keeps its place behind the frame that proved await vi.waitFor(() => expect(seqOf(dispatchRow(sent, 'rejected'))).toBeDefined()) await host.flushStreamedEvents(SESSION) - const ended = seqOf((row) => turnRow(row) && field(row, 'body', 'state') !== 'running') + const ended = seqOf((entry) => { + const turn = turnOf(entry) + return turn !== null && turn.state !== 'running' + }) expect(ended).toBeDefined() expect(seqOf(dispatchRow(sent, 'rejected'))).toBeGreaterThan(ended!) }) @@ -201,9 +208,7 @@ describe('a settlement Codex proves keeps its place behind the frame that proved await vi.waitFor(() => expect(seqOf(dispatchRow(sent, 'accepted'))).toBeDefined()) await host.flushStreamedEvents(SESSION) // The echo reconciles into the send's own bubble: its row is the turn now naming that send. - const echoed = seqOf( - (row) => turnRow(row) && field(row, 'body', 'userItemId') === `orca:${sent}` - ) + const echoed = seqOf((entry) => turnOf(entry)?.userItemId === `orca:${sent}`) expect(echoed).toBeDefined() expect(seqOf(dispatchRow(sent, 'accepted'))).toBeGreaterThan(echoed!) }) From 5f20c7b460a7b955eb7fec736bb0d29574dc0294 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:41:53 -0700 Subject: [PATCH 26/27] fix(native-chat): an empty delta still owes its emit, just not a forced one The previous fix stopped an empty delta from marking the stream dirty, which broke the pinned contract that an empty stream is snapshotted and flushed. The coalescer now marks a stream dirty on every delta and tracks separately whether its text changed since the last emit; only a changed snapshot is the opening one that skips the growth throttle. --- .../agent-session-delta-coalescer.test.ts | 20 ++++++++++++++---- .../agent-session-delta-coalescer.ts | 21 +++++++++++++------ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts index 0c22a7434e11..f49e7a28a8fc 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts @@ -124,15 +124,27 @@ describe('agent-session delta coalescer', () => { expect(emitted).toEqual([['item-1', 'hi']]) }) - it('owes no emit for an empty delta, even as the forced first snapshot', () => { + // Forced past a growth throttle, an unchanged snapshot would rewrite the row with the same text. + it('never marks a snapshot an empty delta left unchanged as the opening', () => { const clock = manualClock() - const { instance, emitted } = coalescer(clock) + const emits: [string, boolean][] = [] + const instance = createAgentSessionDeltaCoalescer({ + emit: (_key, text, _snapshot, opening) => emits.push([text, opening]), + schedule: clock.schedule + }) - instance.append('item-1', 'Hello there') + instance.append('item-1', 'Hello') instance.append('item-1', '') clock.fire() + instance.append('item-1', ' there') + clock.fire() - expect(emitted).toEqual([['item-1', 'Hello there']]) + // The first snapshot that changed the text is still the opening one. + expect(emits).toEqual([ + ['Hello', true], + ['Hello', false], + ['Hello there', true] + ]) }) it('flushes pending text ahead of a lifecycle event and cancels the window', () => { diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index 027b31e6b9a8..072a0e299f90 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -34,7 +34,8 @@ export type AgentSessionDeltaSnapshot = { export type AgentSessionDeltaCoalescerDeps = { /** Called with the FULL text accumulated for the key, not the increment. `opening` marks the - * row-creating emit and the first snapshot after it, which a growth throttle must not skip. */ + * row-creating emit and the first snapshot after it that changed the text, which a growth + * throttle must not skip. */ emit: ( key: string, text: string, @@ -90,7 +91,10 @@ export function createAgentSessionDeltaCoalescer( observedBytes: number truncated: boolean dirty: boolean - /** Successful emits, counted to 2: the first makes the row, the second is its first snapshot. */ + /** The text differs from the last successful emit's. */ + changed: boolean + /** Successful emits that changed the text, counted to 2: the first makes the row, the second + * is its first snapshot. */ emits: number } >() @@ -108,13 +112,16 @@ export function createAgentSessionDeltaCoalescer( key, text, { text, observedBytes: stream.observedBytes, truncated: stream.truncated }, - stream.emits < 2 + stream.emits < 2 && stream.changed ) if (emitted === false) { return false } stream.dirty = false - stream.emits = Math.min(2, stream.emits + 1) + if (stream.changed) { + stream.changed = false + stream.emits = Math.min(2, stream.emits + 1) + } return true } @@ -171,6 +178,7 @@ export function createAgentSessionDeltaCoalescer( observedBytes: 0, truncated: false, dirty: false, + changed: true, emits: 0 } if (!deps.isProtected?.(key)) { @@ -195,9 +203,10 @@ export function createAgentSessionDeltaCoalescer( stream.chunks = next.chunks stream.retainedBytes = next.retainedBytes stream.truncated = next.truncated - // An empty delta changes no text, so it owes no emit. + stream.dirty = true + // An empty delta still owes an emit, but not a forced one: its text is unchanged. if (deltaBytes > 0) { - stream.dirty = true + stream.changed = true } } streams.set(key, stream) From a18b3e8d92a5167b7a36e9a2237f00581c72660b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:36:58 -0700 Subject: [PATCH 27/27] revert(native-chat): drop the first-text row write from the delta coalescer The immediate first-delta emit (and the opening flag and counters it needed) had no Orca-observed failure behind it and added a journal commit per streamed item. The coalescer, the Claude checkpoints and the tests that pinned the first-text row go back to main's behaviour; the one ordered writer, the sink hand-off and the Stop rules stay. --- .../claude-streamed-text-checkpoints.test.ts | 29 +-- .../claude-streamed-text-checkpoints.ts | 2 +- ...ude-structured-journal-translation.test.ts | 10 +- .../codex/codex-structured-item-streams.ts | 4 +- ...ctured-journal-translation-streams.test.ts | 52 +---- ...dex-structured-journal-translation.test.ts | 16 +- .../agent-session-delta-coalescer.test.ts | 81 +------ .../agent-session-delta-coalescer.ts | 46 +--- .../provider-turn-activity-routing.test.ts | 3 +- ...-agent-session-streamed-text-order.test.ts | 205 ------------------ ...ructured-agent-session-integration.test.ts | 19 +- 11 files changed, 45 insertions(+), 422 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts diff --git a/src/main/claude/claude-streamed-text-checkpoints.test.ts b/src/main/claude/claude-streamed-text-checkpoints.test.ts index a8b99b945c28..89f825bc5605 100644 --- a/src/main/claude/claude-streamed-text-checkpoints.test.ts +++ b/src/main/claude/claude-streamed-text-checkpoints.test.ts @@ -88,36 +88,14 @@ describe('claude streamed text checkpoints', () => { it('rewrites a block row with the full text accumulated so far', () => { const { store, rows, runWindow } = checkpoints() - const rest = 'lo, reading the failing test before the fix' store.append(identityOf('block-1'), 'hel') - // The first delta makes the row at once; the window folds the rest into one rewrite. - expect(rows).toEqual([{ uuid: 'block-1', text: 'hel' }]) - store.append(identityOf('block-1'), rest) + store.append(identityOf('block-1'), 'lo') runWindow() - expect(rows).toEqual([ - { uuid: 'block-1', text: 'hel' }, - { uuid: 'block-1', text: `hel${rest}` } - ]) + expect(rows).toEqual([{ uuid: 'block-1', text: 'hello' }]) expect(store.pending).toBe(1) }) - it("writes the window's first snapshot however little it grew the row, then widens", () => { - const { store, rows, runWindow } = checkpoints() - - store.append(identityOf('block-1'), 'I') - store.append(identityOf('block-1'), "'ll check") - runWindow() - // The row-creating write is not a checkpoint: the window's snapshot lands in full. - expect(rows).toEqual([ - { uuid: 'block-1', text: 'I' }, - { uuid: 'block-1', text: "I'll check" } - ]) - store.append(identityOf('block-1'), ' the tests.') - runWindow() - expect(rows).toHaveLength(2) - }) - it('drops every block still awaiting its final frame at settlement', () => { const { store, rows, runWindow } = checkpoints() @@ -242,12 +220,11 @@ describe('claude streamed text checkpoints', () => { const { store, rows, runWindow } = checkpoints() store.append(identityOf('block-1'), 'text') - store.append(identityOf('block-1'), ' and the rest of it, long enough to checkpoint') store.dispose() runWindow() store.flush() - expect(rows).toEqual([{ uuid: 'block-1', text: 'text' }]) + expect(rows).toEqual([]) expect(store.pending).toBe(0) }) }) diff --git a/src/main/claude/claude-streamed-text-checkpoints.ts b/src/main/claude/claude-streamed-text-checkpoints.ts index 364e3aa01bb4..ad21ee790a60 100644 --- a/src/main/claude/claude-streamed-text-checkpoints.ts +++ b/src/main/claude/claude-streamed-text-checkpoints.ts @@ -112,7 +112,7 @@ export function createClaudeStreamedTextCheckpoints( const coalescer = createAgentSessionDeltaCoalescer({ ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), - emit: (key, text, _snapshot, opening) => persist(key, text, opening) + emit: (key, text) => persist(key, text, false) }) const drop = (key: string): void => { diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 6c62181173db..0cf7b30368a7 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -211,7 +211,7 @@ describe('Claude structured journal translation', () => { messageId: 'msg_01', startUuid: 'block-start-1', finalUuid: 'assistant-final-1', - chunks: ['ST', 'REAMOK_ELEC_64E632', ' streamed on past the next checkpoint'] + chunks: ['ST', 'REAMOK_ELEC_64E632'] }) const streamedIdentity = { provider: 'claude', @@ -230,13 +230,7 @@ describe('Claude structured journal translation', () => { for (const delta of turn.deltas) { translator.handle(delta) } - // The first delta made the block's row at once; the rest waits for the window. - expect(assistantMessages(state.items)).toEqual([ - { - identity: streamedIdentity, - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ST' }] } - } - ]) + expect(assistantMessages(state.items)).toEqual([]) const run = scheduled as (() => void) | null run?.() diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index 148cf9caa356..33b3fa7b3cb9 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -142,8 +142,8 @@ export function createCodexStructuredItemStreams( maxTotalRetainedBytes: deps.maxTotalRetainedBytes, isProtected: (key) => states.isPersistent(key), schedule: deps.schedule, - emit: (key, text, _snapshot, opening) => { - return persist(key, text, opening) + emit: (key, text) => { + return persist(key, text, false) } }) diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 2d3b971dadcd..54a2c017aa05 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -567,59 +567,11 @@ describe('codex journal translation', () => { translator.handle( notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) ) - translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'begun' })) - translator.handle( - notification('item/agentMessage/delta', { - itemId: 'item-1', - delta: ' and then gone, before the window closed on it' - }) - ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'gone' })) translator.dispose() window.fire() - // Only the row the first delta made at once. - expect(tap.rows.map((row) => row.body)).toEqual([ - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'begun' }] } - ]) - }) - - it('writes no second revision for an empty delta after the first', () => { - const { translator, tap, window } = translatorWith() - translator.handle(TURN_STARTED) - translator.handle( - notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) - ) - for (const delta of ['Hello there', '']) { - translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta })) - } - window.fire() - - expect(tap.rows.map((row) => row.body)).toEqual([ - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Hello there' }] } - ]) - translator.dispose() - }) - - // The row-creating write is not a checkpoint, so a short headline is not held back behind it. - it("writes a reasoning row's first window in full after the section break that made it", () => { - const { translator, tap, window } = translatorWith() - translator.handle(TURN_STARTED) - const reasoning = { turnId: TURN_ID, itemId: 'reasoning-1', summaryIndex: 0 } - translator.handle(notification('item/reasoning/summaryPartAdded', reasoning)) - for (const delta of ['**Inspecting', ' tests**']) { - translator.handle(notification('item/reasoning/summaryTextDelta', { ...reasoning, delta })) - } - window.fire() - - expect(tap.rows.map((row) => row.body)).toEqual([ - { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text: '\n' }] }, - { - kind: 'message', - role: 'reasoning', - blocks: [{ type: 'text', text: '\n**Inspecting tests**' }] - } - ]) - translator.dispose() + expect(tap.rows).toEqual([]) }) }) diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index e3f259883e1f..23249a959b76 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -577,17 +577,17 @@ describe('codex journal translation', () => { translator.handle( notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) ) - const rest = 'llo, and on past the next checkpoint' translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'he' })) - translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: rest })) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'llo' })) window.fire() - // `item/started` had no text to journal; the first delta makes the row, the window its snapshot. - const reply = (text: string) => ({ - key: 'codex:thread-abc:turn-1:0', - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } - }) - expect(tap.rows).toEqual([reply('he'), reply(`he${rest}`)]) + // `item/started` had no text to journal; only the coalesced snapshot lands. + expect(tap.rows).toEqual([ + { + key: 'codex:thread-abc:turn-1:0', + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] } + } + ]) }) it('upserts the streamed text and the completed body onto one row, body last', () => { diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts index f49e7a28a8fc..fcfafac9ae96 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts @@ -40,49 +40,24 @@ function coalescer(clock: ReturnType, windowMs?: number) { } describe('agent-session delta coalescer', () => { - // The first delta makes the row, so a write issued after the text began lands below it. - it("emits a stream's first delta at once, then folds a burst into one emit on one shared window", () => { + it('folds a burst into one emit carrying the full text, on one shared window', () => { const clock = manualClock() const { instance, emitted } = coalescer(clock) instance.append('item-1', 'he') - expect(emitted).toEqual([['item-1', 'he']]) - expect(clock.windows()).toEqual([]) instance.append('item-1', 'llo') - instance.append('item-1', ' there') instance.append('item-2', 'world') - expect(emitted).toEqual([ - ['item-1', 'he'], - ['item-2', 'world'] - ]) + expect(emitted).toEqual([]) expect(clock.windows()).toEqual([AGENT_SESSION_DELTA_COALESCE_MS]) clock.fire() expect(emitted).toEqual([ - ['item-1', 'he'], - ['item-2', 'world'], - ['item-1', 'hello there'] + ['item-1', 'hello'], + ['item-2', 'world'] ]) }) - // A growth throttle behind emit must not count the row-creating write as a checkpoint. - it('marks the row-creating emit and the first snapshot after it as the opening', () => { - const clock = manualClock() - const openings: boolean[] = [] - const instance = createAgentSessionDeltaCoalescer({ - emit: (_key, _text, _snapshot, opening) => openings.push(opening), - schedule: clock.schedule - }) - - for (const delta of ['I', "'ll", ' check']) { - instance.append('item-1', delta) - clock.fire() - } - - expect(openings).toEqual([true, true, false]) - }) - it('emits the accumulated snapshot again, not the increment, on the next window', () => { const clock = manualClock() const { instance, emitted } = coalescer(clock) @@ -107,10 +82,7 @@ describe('agent-session delta coalescer', () => { } clock.fire() - expect(emitted).toEqual([ - ['item-1', 'x'], - ['item-1', 'x'.repeat(10_000)] - ]) + expect(emitted).toEqual([['item-1', 'x'.repeat(10_000)]]) }) it('does not re-emit a stream with no new text', () => { @@ -124,29 +96,6 @@ describe('agent-session delta coalescer', () => { expect(emitted).toEqual([['item-1', 'hi']]) }) - // Forced past a growth throttle, an unchanged snapshot would rewrite the row with the same text. - it('never marks a snapshot an empty delta left unchanged as the opening', () => { - const clock = manualClock() - const emits: [string, boolean][] = [] - const instance = createAgentSessionDeltaCoalescer({ - emit: (_key, text, _snapshot, opening) => emits.push([text, opening]), - schedule: clock.schedule - }) - - instance.append('item-1', 'Hello') - instance.append('item-1', '') - clock.fire() - instance.append('item-1', ' there') - clock.fire() - - // The first snapshot that changed the text is still the opening one. - expect(emits).toEqual([ - ['Hello', true], - ['Hello', false], - ['Hello there', true] - ]) - }) - it('flushes pending text ahead of a lifecycle event and cancels the window', () => { const clock = manualClock() const { instance, emitted } = coalescer(clock) @@ -202,10 +151,7 @@ describe('agent-session delta coalescer', () => { expect(instance.snapshot('first')?.text).toBe('preserve me') reject = false expect(instance.append('second', 'new stream')).toBe(true) - expect(emitted).toEqual([ - ['first', 'preserve me'], - ['second', 'new stream'] - ]) + expect(emitted).toEqual([['first', 'preserve me']]) }) it('drops a forgotten stream without emitting it, because its final body already landed', () => { @@ -214,28 +160,21 @@ describe('agent-session delta coalescer', () => { instance.append('item-1', 'stale') instance.append('item-2', 'kept') - instance.append('item-1', ' and more') - instance.append('item-2', ' too') instance.forget('item-1') clock.fire() - expect(emitted).toEqual([ - ['item-1', 'stale'], - ['item-2', 'kept'], - ['item-2', 'kept too'] - ]) + expect(emitted).toEqual([['item-2', 'kept']]) }) it('emits nothing after dispose, and leaves no timer behind', () => { const clock = manualClock() const { instance, emitted } = coalescer(clock) - instance.append('item-1', 'begun') - instance.append('item-1', ' and gone') + instance.append('item-1', 'gone') instance.dispose() clock.fire() - expect(emitted).toEqual([['item-1', 'begun']]) + expect(emitted).toEqual([]) expect(clock.pendingCount()).toBe(0) }) @@ -244,7 +183,6 @@ describe('agent-session delta coalescer', () => { const { instance } = coalescer(clock, 5) instance.append('item-1', 'x') - instance.append('item-1', 'y') expect(clock.windows()).toEqual([5]) }) @@ -265,7 +203,6 @@ describe('agent-session delta coalescer', () => { clock.fire() expect(emitted).toEqual([ - { text: 'éé', observedBytes: 4, truncated: false }, { text: 'ééé\n[Orca: streamed output truncated]', observedBytes: 48, diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index 072a0e299f90..1d5608150eff 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -9,10 +9,7 @@ // // The window applies to text only. Lifecycle — an item completing, a turn // ending, an approval arriving — bypasses it by flushing first, so nothing can -// be journaled ahead of the text that preceded it. A stream's first delta is -// emitted at once, so its row takes its place in the journal when the text -// begins and any write issued after lands below it; the window only delays -// later snapshots of that row. +// be journaled ahead of the text that preceded it. /** Long enough to fold a burst of tokens into one row, short enough that the * text still reads as streaming. */ @@ -33,15 +30,8 @@ export type AgentSessionDeltaSnapshot = { } export type AgentSessionDeltaCoalescerDeps = { - /** Called with the FULL text accumulated for the key, not the increment. `opening` marks the - * row-creating emit and the first snapshot after it that changed the text, which a growth - * throttle must not skip. */ - emit: ( - key: string, - text: string, - snapshot: AgentSessionDeltaSnapshot, - opening: boolean - ) => unknown + /** Called with the FULL text accumulated for the key, not the increment. */ + emit: (key: string, text: string, snapshot: AgentSessionDeltaSnapshot) => unknown windowMs?: number maxRetainedBytes?: number maxTotalRetainedBytes?: number @@ -91,11 +81,6 @@ export function createAgentSessionDeltaCoalescer( observedBytes: number truncated: boolean dirty: boolean - /** The text differs from the last successful emit's. */ - changed: boolean - /** Successful emits that changed the text, counted to 2: the first makes the row, the second - * is its first snapshot. */ - emits: number } >() let totalRetainedBytes = 0 @@ -108,20 +93,15 @@ export function createAgentSessionDeltaCoalescer( return true } const text = stream.chunks.join('') - const emitted = deps.emit( - key, + const emitted = deps.emit(key, text, { text, - { text, observedBytes: stream.observedBytes, truncated: stream.truncated }, - stream.emits < 2 && stream.changed - ) + observedBytes: stream.observedBytes, + truncated: stream.truncated + }) if (emitted === false) { return false } stream.dirty = false - if (stream.changed) { - stream.changed = false - stream.emits = Math.min(2, stream.emits + 1) - } return true } @@ -177,9 +157,7 @@ export function createAgentSessionDeltaCoalescer( retainedBytes: 0, observedBytes: 0, truncated: false, - dirty: false, - changed: true, - emits: 0 + dirty: false } if (!deps.isProtected?.(key)) { evictable.add(key) @@ -204,16 +182,8 @@ export function createAgentSessionDeltaCoalescer( stream.retainedBytes = next.retainedBytes stream.truncated = next.truncated stream.dirty = true - // An empty delta still owes an emit, but not a forced one: its text is unchanged. - if (deltaBytes > 0) { - stream.changed = true - } } streams.set(key, stream) - // The first text makes the row; refused under backpressure, it waits for the window. - if (stream.emits === 0 && stream.dirty && stream.retainedBytes > 0 && flushKey(key)) { - return true - } // One timer for every stream: a shared deadline bounds latency the same // way and costs one wakeup per window instead of one per stream. scheduleFlush() diff --git a/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts b/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts index e3eae8082ad5..0067aea8ed5d 100644 --- a/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts @@ -84,8 +84,7 @@ describe('provider turn activity routing', () => { delta: 'Tracing the activity pipeline' }) ) - // The reasoning's own row, made at its first delta; no protocol row beside it. - expect(state.rows.slice(lifecycleRows).map((row) => row.kind)).toEqual(['message']) + expect(state.rows).toHaveLength(lifecycleRows) expect(state.activities.at(-1)?.text).toBe('Tracing the activity pipeline') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts deleted file mode 100644 index 5bf90efc372a..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-streamed-text-order.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -// Text an agent has streamed stays ahead of a host write issued after it, even inside the window -// that coalesces its deltas: the item takes its place in the journal when its first delta arrives, -// and the window only delays the snapshot of its text. Driven through the real host and journal, -// with the real Claude and Codex translators fed synthetic frames; the provider child is a double. - -import { beforeEach, describe, expect, it, type Mock } from 'vitest' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' -import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - attach, - CALLER, - envelope, - hostTestState -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD -} from './structured-agent-session-host-test-data' - -let host: StructuredAgentSessionHost -let acquire: Mock - -beforeEach(() => { - ;({ host, acquire } = hostTestState()) -}) - -const STREAMED = 'Looking at the tests first.' -const LATER_DELTAS = [ - 'ing at the failing tests first,', - ' then the fix that should make them pass.' -] -const LONGER = `Look${LATER_DELTAS.join('')}` - -type Stream = { openTurn: () => void; streamText: (text: string) => void } - -/** The coalescing window, closed only when the test says so. */ -function heldWindow() { - const pending: (() => void)[] = [] - return { - schedule: (run: () => void) => { - pending.push(run) - return () => { - const index = pending.indexOf(run) - if (index !== -1) { - pending.splice(index, 1) - } - } - }, - close: () => { - for (const run of pending.splice(0)) { - run() - } - } - } -} - -function codexStream( - sink: StructuredAgentSessionEventSink, - schedule: Schedule -): Stream & { completeText: (text: string) => void } { - const translator = createCodexJournalTranslator({ - sink, - sessionId: SESSION, - primaryThreadId: () => THREAD, - schedule - }) - const notify = (method: string, params: Record) => - translator.handle({ - type: 'notification', - sessionId: SESSION, - threadId: THREAD, - method, - params: { threadId: THREAD, turnId: 'turn-1', turn: { id: 'turn-1' }, ...params } - }) - return { - openTurn: () => { - notify('turn/started', {}) - notify('item/started', { item: { type: 'agentMessage', id: 'reply', text: '' } }) - }, - streamText: (text) => notify('item/agentMessage/delta', { itemId: 'reply', delta: text }), - completeText: (text) => - notify('item/completed', { item: { type: 'agentMessage', id: 'reply', text } }) - } -} - -function claudeStream(sink: StructuredAgentSessionEventSink, schedule: Schedule): Stream { - const translator = createClaudeJournalTranslator({ sink, schedule }) - let frames = 0 - const streamEvent = (event: Record) => - translator.handle({ - type: 'message', - sessionId: SESSION, - message: { - type: 'stream_event', - uuid: `frame-${++frames}`, - session_id: 'claude-session', - parent_tool_use_id: null, - event - } - }) - return { - openTurn: () => { - streamEvent({ - type: 'message_start', - message: { id: 'message-1', role: 'assistant', content: [] } - }) - streamEvent({ - type: 'content_block_start', - index: 0, - content_block: { type: 'text', text: '' } - }) - }, - streamText: (text) => - streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }) - } -} - -type Schedule = ReturnType['schedule'] - -function describeRow(body: AgentJournalItemBody): string { - if (body.kind === 'status') { - return `note: ${body.text}` - } - if (body.kind === 'message') { - const block = body.blocks[0] - return `text: ${block?.type === 'text' ? block.text : ''}` - } - return body.kind -} - -async function streaming( - stream: (sink: StructuredAgentSessionEventSink, schedule: Schedule) => TStream -) { - await attach() - const window = heldWindow() - const provider = stream(acquire.mock.calls.at(-1)![0].events!, window.schedule) - provider.openTurn() - const journal: AgentSessionJournal = host.collaboratorsForTests().sessions.get(SESSION)!.journal - return { window, provider, journal } -} - -async function stopTurn(journal: AgentSessionJournal): Promise { - const turnId = journal.activeTurnId() - expect(turnId).not.toBeNull() - const stopped = await host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId }), - turnId: turnId! - }) - expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) -} - -function rowsOf(journal: AgentSessionJournal): string[] { - return journal - .snapshot() - .items.map((item) => describeRow(item.body)) - .filter((row) => row !== 'turn') -} - -describe.each([ - ['Codex', codexStream], - ['Claude', claudeStream] -])('%s text streamed inside the coalescing window', (_provider, stream) => { - it('lands ahead of the note of a Stop issued in that window', async () => { - const { window, provider, journal } = await streaming(stream) - provider.streamText(STREAMED) - - await stopTurn(journal) - window.close() - - expect(rowsOf(journal)).toEqual([`text: ${STREAMED}`, 'note: Cancellation requested.']) - }) - - it('is rewritten in place, above the note, when the window closes after the Stop', async () => { - const { window, provider, journal } = await streaming(stream) - provider.streamText('Look') - for (const delta of LATER_DELTAS) { - provider.streamText(delta) - } - expect(rowsOf(journal)).toEqual(['text: Look']) - - await stopTurn(journal) - window.close() - - expect(rowsOf(journal)).toEqual([`text: ${LONGER}`, 'note: Cancellation requested.']) - }) -}) - -describe('Codex text completed inside the coalescing window', () => { - it('lands its final body on the row its first delta made, above a later Stop note', async () => { - const { window, provider, journal } = await streaming(codexStream) - provider.streamText('Look') - provider.streamText('ing at the failing') - provider.completeText(LONGER) - - await stopTurn(journal) - window.close() - - expect(rowsOf(journal)).toEqual([`text: ${LONGER}`, 'note: Cancellation requested.']) - }) -}) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 8632f8887350..3d9e72f74046 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -599,9 +599,9 @@ describe('a structured codex session over agentSession.*', () => { codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Two ' }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'files.' }) await drainStreamedEvents() - // The first delta made the row at once; the 60ms window still holds the second. - const streamed = itemsOf(stream).filter((item) => textOf(item).startsWith('Two')) - expect(streamed.map(textOf)).toEqual(['Two ']) + // The 60ms window has not elapsed, so no half-written row reached the + // journal — the coalescer is holding both deltas. + expect(itemsOf(stream).filter((item) => textOf(item).startsWith('Two'))).toEqual([]) codex.notify('item/completed', { item: { type: 'agentMessage', id: 'item-1', text: 'Two files.' } @@ -792,6 +792,12 @@ describe('a structured codex session over agentSession.*', () => { }) await drainStreamedEvents() + codex.notify('item/agentMessage/delta', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-final', + delta: 'Final text before shutdown.' + }) const host = getStructuredAgentSessionHost() const journal = ( host as unknown as { sessions: Map } @@ -804,13 +810,6 @@ describe('a structured codex session over agentSession.*', () => { await appendGate.promise return originalAppend(...args) }) - // The text's first delta writes its row at once: still landing when teardown begins. - codex.notify('item/agentMessage/delta', { - threadId: THREAD, - turnId: TURN, - itemId: 'item-final', - delta: 'Final text before shutdown.' - }) let stopped = false const stopping = stopStructuredAgentSessionRuntime().then(() => {