mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
fix(claude): a queued message Claude withdrew is settled from Claude's own cancelled event (#23862)
* 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. * 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`. * fix(claude): keep a command's started mark when a redelivery re-emits queued; fixtures name msg_lifecycle_v1
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{"at": 1, "kind": "meta", "scenario": "auth-failed", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."}
|
||||
{"at": 288, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "39446645-6ec0-4807-9e18-95ac04c13143", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"}
|
||||
{"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "b8e8b60c-1592-4f05-a21a-853f6a1a5040", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 302, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "queued", "uuid": "17b4b010-4615-4bf0-9b63-7936336cd7c4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 306, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "started", "uuid": "b1ed2141-c5a2-4c7e-8403-dbeb53d15be2", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 319, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "61043fb2-ec0d-4652-9ab8-00483ac5f026", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}}
|
||||
{"at": 321, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ad41aad3-ab9d-4b3c-a9f2-8610044d9482"}}
|
||||
{"at": 342, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "timestamp": "2026-09-29T10:33:13.793Z", "isReplay": true}}
|
||||
{"at": 342, "kind": "frame", "frame": {"type": "assistant", "message": {"diagnostics": null, "id": "58f67b61-a560-4d83-b099-4f1b2e920de8", "container": null, "model": "<synthetic>", "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"}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
{"at": 3, "kind": "meta", "scenario": "interrupt-lost", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."}
|
||||
{"at": 333, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "a8c39655-a598-42a7-943e-899b511643d9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 362, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "1bc17c32-c0d8-4c1d-ace0-03327bbcf13b", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 416, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}
|
||||
{"at": 421, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "ef60bf56-2954-4f1a-bfaf-502f4cf7e5f9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 421, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "queued", "uuid": "8f4de258-0da7-4ff7-a972-24c5d2b9e593", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 422, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "started", "uuid": "e70550c4-18ff-4795-9ddf-9dd267e82b9e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 488, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "31b93145-d113-4141-adb1-2174b07845cd", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}}
|
||||
{"at": 490, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "38344e0c-d00c-40dd-9da2-6cd73d7ad800"}}
|
||||
{"at": 2520, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "timestamp": "2026-09-29T10:35:25.741Z", "isReplay": true}}
|
||||
{"at": 3136, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "d15945e1-6168-458e-9b2d-49f427327af2", "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda"}}
|
||||
{"at": 3137, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"}
|
||||
{"at": 3137, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "queued", "uuid": "f6865348-9b42-410c-96e8-5bf01e1d68de", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 3137, "kind": "control", "request": {"subtype": "interrupt", "cancel_queued": true}}
|
||||
{"at": 3138, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "cancelled", "uuid": "d4cf2dfc-2530-4fa6-9f47-efab0874177a", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 3138, "kind": "control-answer", "response": {"still_queued": [], "cancelled": ["0ca6fc96-94c3-4462-9524-a8341729ab91"]}}
|
||||
{"at": 3139, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "[Request interrupted by user]"}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ce4fe796-391f-4acc-b37c-49b668682592", "timestamp": "2026-09-29T10:35:28.422Z"}}
|
||||
{"at": 3149, "kind": "frame", "frame": {"duration_api_ms": 2176, "stop_reason": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.005256, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1209, "outputTokens": 21, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.005256, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "aborted_streaming", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 2, "subtype": "error_during_execution", "errors": ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"], "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "type": "result", "duration_ms": 2717, "uuid": "9551c994-3a32-4f9a-b205-be27ae648b93", "user_message_uuids": ["d0649b29-11d5-45e7-9034-6fb789e78eda"], "queued_turn_count": 0, "result_index": 0}}
|
||||
{"at": 3150, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "cancelled", "uuid": "24526d3c-00cc-488a-b899-239756190772", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
{"at": 3150, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "5de7c47b-2d4f-4f66-b301-d5e4de20e787", "session_id": "00000000-0000-4000-8000-00000000c1a0"}}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Replays of real Claude CLI 2.1.280 sessions (`__fixtures__/claude-lifecycle-capture-*.jsonl`)
|
||||
// in which the CLI withdrew a queued send, interrupted a turn, or failed one. The recorded
|
||||
// frames are the script; at each recorded control request the test decides how Orca's side of
|
||||
// it went — the answer arriving, lost, or failing — and the CLI's own frames from while that
|
||||
// request was outstanding are delivered either way.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
|
||||
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
|
||||
import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection'
|
||||
import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests'
|
||||
import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter'
|
||||
import type { ClaudeLateDispatchOutcome } from './claude-structured-session-state'
|
||||
import {
|
||||
fakeClaude,
|
||||
identityFor,
|
||||
PROVIDER_SESSION_ID,
|
||||
type FakeConnection
|
||||
} from './claude-structured-session-test-support'
|
||||
|
||||
type CapturedEvent =
|
||||
| { kind: 'meta'; providerSessionId: string }
|
||||
| { kind: 'frame'; frame: Record<string, unknown> }
|
||||
| { kind: 'dispatch'; clientMessageId: string; sentUuid: string; text: string }
|
||||
| { kind: 'control'; request: Record<string, unknown> }
|
||||
| { kind: 'control-answer'; response: Record<string, unknown> }
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown>
|
||||
/** The CLI's answer as recorded, mapped to this replay's uuids. */
|
||||
answer: Record<string, unknown>
|
||||
/** Delivers the frames the CLI emitted while the request was outstanding. */
|
||||
deliverInFlight: () => void
|
||||
adapter: ClaudeStructuredSessionAdapter
|
||||
connection: FakeConnection
|
||||
routes: ReturnType<typeof fakeClaude>['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<void>
|
||||
/** 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<string, unknown>) => boolean
|
||||
/** Frames to deliver right after a captured one, in the capture's own uuids. */
|
||||
afterFrame?: (frame: Record<string, unknown>) => Record<string, unknown>[]
|
||||
/** Stop after the control request settles; its tail answers the CLI's own control path. */
|
||||
stopAfterControl?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const capture = loadCapture(name)
|
||||
const settlements: Settlement[] = []
|
||||
const idles: string[] = []
|
||||
const turnStates = new Map<string, string>()
|
||||
// The capture supplies every frame, startup proof included.
|
||||
const claude = fakeClaude({ initProof: 'none', replayUuid: null })
|
||||
const adapter = new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
pathToClaudeCodeExecutable: 'claude',
|
||||
options: {},
|
||||
cwd: '/work/repo',
|
||||
claudeConfigDir: '/accounts/claude',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
resumeLeafUuid: null,
|
||||
resumesTranscript: false,
|
||||
continuesChain: false
|
||||
}),
|
||||
openConnection: claude.openConnection,
|
||||
readProcessStartTime: async () => 1,
|
||||
now: () => 1_700_000_200_000,
|
||||
persistHandle: async () => {},
|
||||
onDispatchSettledLate: (settlement) => settlements.push(settlement),
|
||||
onSessionIdle: ({ sessionId }) => idles.push(sessionId)
|
||||
})
|
||||
await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
events: {
|
||||
appendItem: (_identity, body: AgentJournalItemBody) => {
|
||||
const turn = readAgentJournalTurn(body)
|
||||
if (turn) {
|
||||
turnStates.set(turn.turnId, turn.state)
|
||||
}
|
||||
},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
})
|
||||
const connection = claude.connections[0]!
|
||||
|
||||
// Captured uuids -> the uuids the live dispatches mint during this replay.
|
||||
const uuidMap = new Map<string, string>()
|
||||
const capturedSessionId = capture.flatMap((event) =>
|
||||
event.kind === 'meta' ? [event.providerSessionId] : []
|
||||
)[0]!
|
||||
const mapUuids = (value: Record<string, unknown>): Record<string, unknown> => {
|
||||
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<string, unknown>): void => {
|
||||
if (options.omitFrame?.(frame)) {
|
||||
return
|
||||
}
|
||||
const mapped = mapUuids(frame)
|
||||
if (Array.isArray(mapped.capabilities) && options.withoutCapabilities) {
|
||||
mapped.capabilities = mapped.capabilities.filter(
|
||||
(capability) => !options.withoutCapabilities!.includes(String(capability))
|
||||
)
|
||||
}
|
||||
connection.handlers.onMessage?.(mapped)
|
||||
for (const extra of options.afterFrame?.(frame) ?? []) {
|
||||
connection.handlers.onMessage?.(mapUuids(extra))
|
||||
}
|
||||
}
|
||||
const liveUuid = (clientMessageId: string): string => {
|
||||
const dispatch = capture.find(
|
||||
(event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId
|
||||
)
|
||||
return dispatch?.kind === 'dispatch' ? (uuidMap.get(dispatch.sentUuid) ?? '') : ''
|
||||
}
|
||||
|
||||
let proofDelivered = false
|
||||
for (let index = 0; index < capture.length; index++) {
|
||||
const event = capture[index]!
|
||||
if (event.kind === 'frame') {
|
||||
deliver(event.frame)
|
||||
proofDelivered = true
|
||||
} else if (event.kind === 'dispatch') {
|
||||
if (proofDelivered) {
|
||||
await adapter.awaitStarted('session-1')
|
||||
}
|
||||
await expect(
|
||||
adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: event.clientMessageId,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: event.text }] },
|
||||
fence: 7
|
||||
})
|
||||
).resolves.toEqual({ state: 'admitted' })
|
||||
uuidMap.set(event.sentUuid, String(connection.sent.at(-1)!.uuid))
|
||||
} else if (event.kind === 'control') {
|
||||
const answerAt = capture.findIndex(
|
||||
(candidate, at) => at > index && candidate.kind === 'control-answer'
|
||||
)
|
||||
if (answerAt === -1) {
|
||||
throw new Error('capture recorded no answer for its control request')
|
||||
}
|
||||
const inFlight = capture.slice(index + 1, answerAt)
|
||||
const answer = capture[answerAt]
|
||||
let delivered = false
|
||||
const deliverInFlight = (): void => {
|
||||
if (!delivered) {
|
||||
delivered = true
|
||||
for (const frameEvent of inFlight) {
|
||||
if (frameEvent.kind === 'frame') {
|
||||
deliver(frameEvent.frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const atControl = options.atControl ?? (async (point) => point.deliverInFlight())
|
||||
await atControl({
|
||||
request: mapUuids(event.request),
|
||||
answer: answer?.kind === 'control-answer' ? mapUuids(answer.response) : {},
|
||||
deliverInFlight,
|
||||
adapter,
|
||||
connection,
|
||||
routes: claude.routes,
|
||||
liveUuid
|
||||
})
|
||||
deliverInFlight()
|
||||
if (options.stopAfterControl) {
|
||||
break
|
||||
}
|
||||
index = answerAt
|
||||
}
|
||||
}
|
||||
const settlementsFor = (clientMessageId: string) =>
|
||||
settlements
|
||||
.filter((settlement) => settlement.clientMessageId === clientMessageId)
|
||||
.map(({ sessionId: _sessionId, clientMessageId: _id, ...outcome }) => outcome)
|
||||
return { settlementsFor, idles, turnStates, liveUuid, connection, adapter }
|
||||
}
|
||||
|
||||
const WITHDRAWN = {
|
||||
state: 'rejected',
|
||||
reason: DISPATCH_REJECTED_CANCELLED,
|
||||
rejection: { kind: 'cancelled' }
|
||||
}
|
||||
|
||||
function acceptedAs(uuid: string) {
|
||||
return { providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid } }
|
||||
}
|
||||
|
||||
/** Orca's Stop with no turn named, as the chat sends it; the interrupt answers per `answer`. */
|
||||
function stopWithInterruptAnswer(answer: 'recorded' | 'lost') {
|
||||
return async (point: ControlPoint): Promise<void> => {
|
||||
point.routes.interrupt = () => {
|
||||
point.deliverInFlight()
|
||||
if (answer === 'lost') {
|
||||
throw new ClaudeControlRequestError('interrupt', 'Control request timed out')
|
||||
}
|
||||
return point.answer
|
||||
}
|
||||
await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })
|
||||
}
|
||||
}
|
||||
|
||||
describe('a Stop whose interrupt cancelled a queued follow-up (cancel_queued)', () => {
|
||||
it.each(['recorded', 'lost'] as const)(
|
||||
'withdraws the follow-up once when the interrupt answer is %s, and leaves the stopped turn’s message accepted',
|
||||
async (answer) => {
|
||||
const replay = await replayCapture('interrupt-lost', {
|
||||
atControl: stopWithInterruptAnswer(answer)
|
||||
})
|
||||
|
||||
expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([
|
||||
{ subtype: 'interrupt', params: { cancelQueued: true } }
|
||||
])
|
||||
expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN])
|
||||
// The interrupted turn's own message was echoed first; its later `cancelled` changes nothing.
|
||||
expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))])
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores a cancelled frame for a send it no longer holds, or never sent', async () => {
|
||||
const replay = await replayCapture('interrupt-lost', {
|
||||
atControl: stopWithInterruptAnswer('lost')
|
||||
})
|
||||
for (const commandUuid of [
|
||||
replay.liveUuid('client-A'),
|
||||
replay.liveUuid('client-B'),
|
||||
'5a1b0c63-0000-4000-8000-000000000000'
|
||||
]) {
|
||||
replay.connection.handlers.onMessage?.({
|
||||
type: 'command_lifecycle',
|
||||
command_uuid: commandUuid,
|
||||
state: 'cancelled',
|
||||
uuid: `lifecycle-${commandUuid}`,
|
||||
session_id: PROVIDER_SESSION_ID
|
||||
})
|
||||
}
|
||||
|
||||
expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))])
|
||||
expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a Stop that withdraws the follow-up one at a time (no cancel_queued)', () => {
|
||||
it.each([
|
||||
[
|
||||
'times out',
|
||||
() => {
|
||||
throw new ClaudeControlRequestError('cancel_async_message', 'Control request timed out')
|
||||
}
|
||||
],
|
||||
[
|
||||
'errors',
|
||||
() => {
|
||||
throw new Error('Query closed before response received')
|
||||
}
|
||||
],
|
||||
['answers false', () => false]
|
||||
] as const)(
|
||||
'withdraws it from the CLI’s cancelled frame when cancel_async_message %s',
|
||||
async (_label, answer) => {
|
||||
const replay = await replayCapture('cancel-async', {
|
||||
withoutCapabilities: ['interrupt_cancel_queued_v1'],
|
||||
stopAfterControl: true,
|
||||
atControl: async (point) => {
|
||||
const queued = point.liveUuid('client-B')
|
||||
point.routes.interrupt = () => ({ still_queued: [queued] })
|
||||
point.routes.cancel_async_message = () => {
|
||||
point.deliverInFlight()
|
||||
return answer()
|
||||
}
|
||||
await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })
|
||||
}
|
||||
})
|
||||
|
||||
expect(replay.connection.calls.map((call) => call.subtype)).toContain('cancel_async_message')
|
||||
expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN])
|
||||
expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('a queued send the CLI withdraws without Orca hearing why', () => {
|
||||
it('settles the withdrawn send from its own cancelled frame', async () => {
|
||||
const replay = await replayCapture('cancel-async')
|
||||
|
||||
expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN])
|
||||
expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))])
|
||||
})
|
||||
|
||||
it('withdraws only the batch lead; the send behind it runs as its own turn and is accepted', async () => {
|
||||
const replay = await replayCapture('batch-lead')
|
||||
|
||||
expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN])
|
||||
expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))])
|
||||
expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))])
|
||||
expect([...replay.turnStates.entries()]).toEqual([
|
||||
[replay.liveUuid('client-A'), 'completed'],
|
||||
[replay.liveUuid('client-C'), 'completed']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a send the CLI started, then cancelled', () => {
|
||||
it('stays accepted when its turn failed after the echo', async () => {
|
||||
const replay = await replayCapture('auth-failed')
|
||||
|
||||
expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))])
|
||||
})
|
||||
|
||||
it('is not read as withdrawn when the cancelled frame came before any echo', async () => {
|
||||
// The auth-failed capture with the turn's output removed: started, then cancelled.
|
||||
const replay = await replayCapture('auth-failed', {
|
||||
omitFrame: (frame) =>
|
||||
frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result'
|
||||
})
|
||||
|
||||
expect(replay.settlementsFor('client-A')).toEqual([])
|
||||
})
|
||||
|
||||
it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => {
|
||||
const replay = await replayCapture('auth-failed', {
|
||||
omitFrame: (frame) =>
|
||||
frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result',
|
||||
afterFrame: (frame) =>
|
||||
frame.type === 'command_lifecycle' && frame.state === 'started'
|
||||
? [{ ...frame, state: 'queued' }]
|
||||
: []
|
||||
})
|
||||
|
||||
expect(replay.settlementsFor('client-A')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the CLI reporting its session idle', () => {
|
||||
it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])(
|
||||
'is reported once per idle frame (%s), never for running',
|
||||
async (name) => {
|
||||
const replay = await replayCapture(name)
|
||||
|
||||
expect(replay.idles).toEqual(['session-1'])
|
||||
}
|
||||
)
|
||||
|
||||
it('is not reported from a child the session no longer holds', async () => {
|
||||
const replay = await replayCapture('auth-failed')
|
||||
await replay.adapter.closeSession('session-1')
|
||||
|
||||
replay.connection.handlers.onMessage?.({
|
||||
type: 'system',
|
||||
subtype: 'session_state_changed',
|
||||
state: 'idle',
|
||||
uuid: 'late-idle',
|
||||
session_id: PROVIDER_SESSION_ID
|
||||
})
|
||||
|
||||
expect(replay.idles).toEqual(['session-1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
// What Claude's per-command `command_lifecycle` frames (msg_lifecycle_v1) settle.
|
||||
//
|
||||
// `cancelled` is not by itself a withdrawal: a command the CLI already started also ends
|
||||
// `cancelled` when its turn is interrupted or fails (measured on 2.1.280). Only a command
|
||||
// cancelled before it started was withdrawn. That frame lands ahead of the control answer, so
|
||||
// it settles the send even when the interrupt or cancel_async_message answer is lost.
|
||||
|
||||
import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch'
|
||||
import { readClaudeFrameString } from './claude-structured-init-proof'
|
||||
import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
export function observeClaudeCommandLifecycle(
|
||||
session: ClaudeSession,
|
||||
message: Record<string, unknown>,
|
||||
onSettledLate?: ClaudeLateDispatchSettlement
|
||||
): void {
|
||||
const commandUuid = readClaudeFrameString(message, 'command_uuid')
|
||||
// An echoed send has left both lists, so nothing here can reach a delivered message.
|
||||
const waiter = [...session.dispatchWaiters, ...session.retiredDispatchWaiters].find(
|
||||
(candidate) => candidate.sentUuid === commandUuid
|
||||
)
|
||||
if (!waiter) {
|
||||
return
|
||||
}
|
||||
const state = message.state
|
||||
if (state === 'started' || (state === 'queued' && waiter.commandLifecycle !== 'started')) {
|
||||
// Forward only: a redelivered command re-emits `queued`, but it has still started.
|
||||
waiter.commandLifecycle = state
|
||||
} else if (state === 'cancelled' && waiter.commandLifecycle !== 'started') {
|
||||
settleCancelledClaudeDispatchWaiters(session, [waiter.sentUuid], onSettledLate)
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
onSettledLate?: ClaudeLateDispatchSettlement
|
||||
): ClaudeReplayTurnOrigin | null {
|
||||
if (message.type === 'command_lifecycle') {
|
||||
observeClaudeCommandLifecycle(session, message, onSettledLate)
|
||||
return null
|
||||
}
|
||||
const envelope = readClaudeMessageEnvelope(message)
|
||||
const isUserReplay =
|
||||
envelope?.role === 'user' &&
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -95,6 +95,8 @@ export type ClaudeStructuredSessionAdapterDeps = {
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
/** Direct settlement path for provider-proven late dispatch outcomes. */
|
||||
onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void
|
||||
/** The CLI reported `session_state_changed idle`, which it sends only once its queue drains. */
|
||||
onSessionIdle?: (input: { sessionId: string }) => void
|
||||
onBackgroundTasksChanged?: (
|
||||
sessionId: string,
|
||||
state: AgentSessionBackgroundTaskState | null
|
||||
@@ -139,6 +141,8 @@ export type ClaudeDispatchWaiter = {
|
||||
settledUuid?: string
|
||||
/** The write failed or the child died, but a replay may still name it. */
|
||||
retired?: boolean
|
||||
/** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */
|
||||
commandLifecycle?: 'queued' | 'started'
|
||||
/** Bounded digest/summary for compatibility CLIs that mint UUIDs. */
|
||||
replayContentKey: string
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, unknown> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -215,6 +215,20 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
|
||||
})
|
||||
)
|
||||
}
|
||||
// The provider going idle is what re-derives a doubted send: it can no longer be holding it.
|
||||
const releaseUnansweredDispatches = ({ sessionId }: { sessionId: string }): void => {
|
||||
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<Install
|
||||
onChildWorkEvidence: (sessionId, evidence) =>
|
||||
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<Install
|
||||
onChildWorkEvidence: (sessionId, evidence) =>
|
||||
host?.publishChildWorkEvidence(sessionId, evidence),
|
||||
onDispatchSettledLate,
|
||||
onSessionIdle: releaseUnansweredDispatches,
|
||||
...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}),
|
||||
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}),
|
||||
modelCatalog: agentModelCatalogStore
|
||||
|
||||
@@ -40,6 +40,7 @@ export type StructuredClaudeRuntimeAdapterDeps = {
|
||||
state: AgentSessionBackgroundTaskState | null
|
||||
) => void
|
||||
onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate']
|
||||
onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle']
|
||||
onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence']
|
||||
}
|
||||
|
||||
@@ -132,6 +133,7 @@ export function createStructuredClaudeRuntimeAdapter(
|
||||
? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged }
|
||||
: {}),
|
||||
...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}),
|
||||
...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}),
|
||||
...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}),
|
||||
...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}),
|
||||
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}),
|
||||
|
||||
Reference in New Issue
Block a user