mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(claude): move structured native chat onto the Claude Agent SDK and enable it on macOS and Linux (#18560)
* Join structured attach teardown through journal bind * fix: restore structured chat parity * feat: add Claude structured session adapter * fix: harden Claude structured adapter * fix: close Claude adapter edge cases * fix: start Claude init deadline after launch * feat: wire Claude structured sessions * fix: harden Claude structured runtime * fix: fence Claude structured compatibility * fix: preserve Claude free-text prompt answers * fix: decode addressed Claude prompt text * feat: enable Claude structured chat on mobile * fix(mobile): keep structured chat provider-aware * fix(mobile): negotiate Claude structured tabs * fix: keep scoped RPC tests native-free * fix: secure mobile structured image delivery * fix: close structured session data-loss gaps * fix: prove real Claude structured startup * fix: consume pre-spawn proof before retry * feat(native-chat): add desktop structured sessions * fix(native-chat): satisfy structured session cleanup gates * fix(native-chat): keep structured renders pure * fix(native-chat): open composer pickers upward * fix(native-chat): use existing view for structured sessions * fix: harden structured desktop status projection * fix: close structured desktop lifecycle gaps * fix: fence structured AI Vault resumes * fix: fence structured AI Vault resumes * fix: preserve structured tabs during activation * feat: toggle structured sessions between chat and TUI * fix: harden structured session handoffs * fix: bind structured TUI before rollout proof * fix: complete structured chat round trips * fix: align structured TUI return readiness * fix(native-chat): make reverse handoff transactional * Add Claude structured TUI handoff seams * fix(native-chat): clear sticky handoff recovery * fix(native-chat): complete mobile reverse after TUI exit * fix(native-chat): keep TUI transcripts readable * fix(native-chat): recover TUI transcript gaps * fix(native-chat): recover claimed TUI owners * fix(native-chat): retain cold TUI proof authority * fix(native-chat): preserve Claude handoff authority * fix(native-chat): recover TUI transcripts read-only * fix(native-chat): harden Claude handoff recovery * fix(native-chat): serialize structured handoff recovery * fix(native-chat): close handoff admission races * fix(native-chat): validate pinned launch environment * fix(native-chat): revalidate restored and retried owners * fix(native-chat): gate restart recovery publications * fix(i18n): catalog Claude session controls * fix(native-chat): wait for structured TUI process proof * fix(native-chat): queue stale idle TUI handoffs * fix(native-chat): route structured Codex options directly * fix(native-chat): persist structured session options * fix(native-chat): hydrate resumed structured options * fix(native-chat): preserve options across structured handoffs * fix(native-chat): replay pending option mutations * fix(native-chat): rotate settled handoff operations * fix(native-chat): rotate refused send operations * test(native-chat): derive refusal retry state from host * test(native-chat): give the host-oracle matrix test an explicit timeout * fix(native-chat): keep Claude option controls idle * fix mobile structured first-send hydration race * fix(native-chat): preserve handoff launch authority * fix(native-chat): harden shared handoff recovery * fix(native-chat): serialize structured handoff recovery * fix(native-chat): close handoff admission races * fix(native-chat): validate pinned launch environment * fix(native-chat): revalidate restored and retried owners * fix(native-chat): gate restart recovery publications * fix(i18n): catalog structured session recovery control * fix(native-chat): wait for structured TUI process proof * fix(native-chat): queue stale idle TUI handoffs * fix(native-chat): keep structured recovery provider-neutral * fix(native-chat): drop local terminal topology from structured sync * fix structured outbox and tab restore races * fix(native-chat): preserve Claude question groups * fix structured provider visibility and request handling * fix structured session TUI handoff recovery * fix reverse structured session handoff * fix(native-chat): recover Claude outbox and resume state * chore(mobile): preserve the working-tree lockfile state before the main merge Carries the pre-existing uncommitted mobile/pnpm-lock.yaml modification into history so the main merge cannot overwrite it. Verified benign pnpm drift (babel 7.29.7->7.29.8 transitives plus deprecation metadata); drops no patchedDependencies (the mobile lockfile declares none). * test(native-chat): drop orphaned Claude handoff-auth test left by the main merge 'pins Claude handoff auth through the terminal provider boundary' is absent from main and its production counterpart preserveClaudeAuthEnv no longer exists outside this test - orphaned residue of the terminal/native handoff work this PR excludes by scope. Removed rather than repaired: the failure was a renamed field (providerHome -> providerRoot), and renaming it would have carried out-of-scope handoff code into the merge. Body preserved as evidence and logged in CLAUDE-STRUCTURED-DISPOSITION-TABLE.md. * Fix mobile structured turn state * fix Claude structured session blockers * fix claude structured lane blockers * fix Claude acquisition exit proof * fix(claude): route stream-json launch through process wrapper * fix(claude): gate structured chat support * Fix Claude structured launch gating * fix(claude): split session acquisition and prune mobile scope * test(claude): align structured session fixtures * fix(agent-session): preserve handoff launch arguments * fix(claude): open journals through the factory after origin/main split The journal opener moved to journal-store-factory on main; retarget the Claude structured tests that still imported the old path. * fix(claude): resolve Claude structured launch args, auth, and win32 proof The origin/main merge re-expressed the lane's Claude wiring onto main's split orca-runtime facade and dropped three wires past green typecheck and lint. - resolveLaunchArgs discarded its provider parameter, so structured Claude sessions were launched with Codex app-server flags; Claude exits on --dangerously-bypass-approvals-and-sandbox, and a Codex arg-parse throw could block Claude session creation outright. - resolveClaudeLaunchEnv was no longer supplied, so the launch resolver fell back to the whole process env as configuredEnv and buildClaudeChildProcessEnv re-applied every auth var it had just stripped. The resolver now merges the Claude overlay onto a strip-applied copy of the inherited env, which also keeps PATH intact for withCliRuntimeOnPath. - The windowsProcessStartTimeAvailable producer was gone while the contract field and both consumers survived, so the renderer gate fail-closed and structured native chat was unreachable on every win32 host. Separately, structured Claude pinned CLAUDE_CONFIG_DIR unconditionally. An explicit pin makes the CLI abandon the macOS Keychain even when it names the CLI's own default, so a default claude.ai account could not authenticate where the legacy Claude terminal could. Pin only a home the CLI would not resolve on its own, matching ClaudeRuntimePathResolver, and compare against the env the child would otherwise inherit so a diverging overlay cannot outrank the record's account home. Also await the now-async revealNativeSession in its regression test, and set the native status before revealing so a rejecting reveal cannot leave a session released but never marked native. Claude-Session: https://claude.ai/code/session_013UqKCRB6k5e8UaYhXUHeWY * fix(claude): scrub case-insensitive Windows auth env * fix(native-chat): settle handoff outcome-write failures instead of leaking them A store write failure while recording a handoff outcome escaped the flow runner's catch handler, so the client never received the failure and the flow surfaced as an unhandled rejection (seen as an intermittent agent_session_store_corrupt error in the proven-dead-retry suite, whose teardown raced the flow's trailing outcome write). Record the failed outcome best-effort, and drain the coordinator before that test's teardown removes the store root. Claude-Session: https://claude.ai/code/session_011aXkcHyeiRJuezupQdjZaM * fix(native-chat): make the structured close-failure toast provider-neutral The structuredSessionCloseFailed toast fires for any structured session, but its copy said 'Codex chat', so a Claude structured session that fails to close showed the wrong provider name. The launch-failure toast is only reachable behind the agent === 'codex' gate, so its copy stays as is. Claude-Session: https://claude.ai/code/session_013ugSpCx4AWkySaJb69BQax * fix(native-chat): wire structured handoff proof recovery * fix(native-chat): wire structured handoff proof recovery * fix(native-chat): correct the structured chat opt-in copy The one `experimentalStructuredNativeChat` toggle gates both providers — `useStructuredAgentSessionCreate` runs `canUseStructuredNativeChat` for `'claude'` as well as `'codex'` — but its description named only Codex. Its scope line also said Windows keeps using terminal chat, while the gate refuses win32 only until the host proves it can read a process start time. `structured-native-chat-availability.test.ts` already pins that Windows is allowed once the proof is cached, so the two contradicted each other. Claude-Session: https://claude.ai/code/session_01RJFsidQWmKYFmeoUuVu4Tp * test(claude): pin @anthropic-ai/claude-agent-sdk 0.3.251 contracts against a scripted CLI PR 1 of the SDK migration: dependency + test-only harness, no product wiring. - Pin @anthropic-ai/claude-agent-sdk to exactly 0.3.251 — not the newest release — because 0.3.251 (published 2026-08-28) clears the repo's 3-day minimumReleaseAge supply-chain gate with no exclusion, while the newest release was minutes old and would have required excluding a brand-new publish from the exact control built to catch brand-new malicious publishes. Every contract this design depends on was verified identical on 0.3.251: the full option surface, no pid on SpawnedProcess (custom spawner stays mandatory), env defaulting to process.env when omitted, and --replay-user-messages appearing only via extraArgs. - Exclude all eight bundled CLI platform binaries via ignoredOptionalDependencies. The setting lives in pnpm-workspace.yaml because pnpm 12 no longer reads the package.json "pnpm" field (it warns and ignores it; verified by install ablation). Excluding the binaries is what makes Orca's pathToClaudeCodeExecutable override mandatory rather than merely preferred. Note: pnpm 12.0.0 honors the ignore list when reconciling an existing lockfile but not on fresh resolution of a new dependency, so the lockfile's SDK entry was pinned surgically; both 'pnpm install' and 'pnpm install --frozen-lockfile' verify clean and stable against the committed lockfile. - Contract-pin suite drives the real SDK against a scripted fake CLI and pins: unknown type/field/content-block pass-through (and keep_alive interception), spawner env fidelity plus the omitted-env process.env inheritance sharp edge, extraArgs producing --replay-user-messages, argument parity for every CLAUDE_STRUCTURED_BASE_ARGS entry plus --session-id/--resume/ --resume-session-at, canUseTool wire request_id stability and abort on control_cancel_request, one spawn per query, pathToClaudeCodeExecutable honored by the default spawner, the exact SDK version, and the eight platform binaries staying uninstalled. Claude-Session: https://claude.ai/code/session_01FGCRfYUnb4hbvfTAHGtJKQ * feat(claude): drive the structured transport through the agent SDK Replaces the hand-rolled `claude -p --input-format stream-json` transport with @anthropic-ai/claude-agent-sdk 0.3.251, keeping the existing connection interface for this commit so the acquisition path changes minimally. The control-plane rewrite is a separate change. Orca still supplies the process. `spawnClaudeCodeProcess` routes through `spawnProcess`, retains the child and its pid — the triple the durable lease adjudicates on — drains stderr so exit errors keep their tail, and hands `.cmd` shims to Orca's Windows argument encoder rather than the SDK's plain spawn. `close()` keeps Orca's own bounded tree-kill and exit deadline, so it still resolves true only after an observed exit. Launch resolution emits an SDK options object instead of argv; durable `launchArgs` translate to a typed option where one exists and to `extraArgs` otherwise, refusing a token neither can carry rather than dropping it. The child env is always passed explicitly — omitting it would let the SDK inherit `process.env` and reintroduce the ambient `ANTHROPIC_*` leak. The stdout line parser is deleted; the SDK owns framing, and unknown frames still reach the translator verbatim. Claude-Session: https://claude.ai/code/session_01JMhFjh9HEnkcJ5YTfCdgD3 * fix(claude): settle the frame the SDK pulled but never wrote The SDK's input pump is `for await (frame of prompt) { await transport.write(frame) }`. When that write rejects — the child dies between Orca's liveness guard and the write — the for-await ends abruptly and calls the generator's `return()`, so the code after `yield` never runs. The frame was already shift()ed out of `queued`, so the later `fail()` from the exit path could not reach it and `send()` never settled: `dispatchClaudeTurn` awaits that send before it can return `unknown`, wedging the caller and the durable outbox. The pre-SDK transport rejected on the stdin write callback instead. Retain the in-flight entry and settle it from the generator's cleanup, and let fail() reach it too for the pump that never resumes at all. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): keep the agent SDK behind the structured-Claude boundary The ordinary OrcaRuntimeService graph statically reaches the Claude adapter and so the transport module, whose first line imported @anthropic-ai/claude-agent-sdk. The SDK is evaluated whenever the regular runtime loads, before any structured Claude session is chosen: it sets process.env.NoDefaultCurrentDirectoryInExePath, changing Windows executable resolution for later subprocesses, and a missing or incompatible install would break normal runtime startup — for a user who never leaves the terminal/TUI path. Defer the SDK to the connection, memoized so it loads once per process, and add the import-graph ratchet: a walk from the Electron main entry that fails on any static import of the package, plus a clean-fork check that loading the runtime leaves the Windows search variable untouched and a child-process pin that the side effect is still real. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): answer list_models so the picker stops serving the seed sendControlRequest had no list_models case, so every request hit the default reject; readClaudeStructuredSessionOptions swallows that with .catch(() => null) and falls back to the static catalog. Every structured session therefore served a hardcoded model list with no per-model effort levels, no resolvedModel and no default detection, and nothing surfaced the failure. The pre-SDK transport got the live catalog from the CLI. Route it through the SDK's supportedModels(), wrapped in the { models } envelope the existing parser reads. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): reap the child's descendants before killing it The forced step of the exit ladder went through the Codex helper, which spawns `pkill -KILL -P <pid>` and SIGKILLs the parent in the same tick: the parent usually dies first, the descendants reparent to pid 1, and `-P` matches nothing. An MCP or launcher descendant of a stubborn Claude child was left running. The test named for that requirement declined to assert it and killed the survivor by hand instead, so it could not fail for the thing it was named after. Route the Claude reap through Orca's existing sweep, which snapshots descendants while their parent link still exists and signals them before the root goes, and on Windows uses the identity-gated `taskkill /T /F`. The test now asserts the descendant is dead; the manual kill stays only as a failure-safe. close() still returns true only on an observed exit. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(native-chat): merge the duplicated handoff type import CI's static-analysis lint (`oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings`) exits 1 on the two separate `import type` statements from the same module. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): answer a permission callback whose signal already aborted settleFrom registered the abort listener and then delivered the request. A callback that arrives already aborted never fires that event, so the promise stayed pending behind a durable prompt with no cancel path. Check the signal first, emit the cancel, and resolve the SDK's null sentinel without registering. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * test(claude): wait for the child to record the frame, not just for its report The scripted CLI writes its report at startup, so `until(readReport)` returned a report with no user messages whenever the child had not yet read the line. The assertion then failed under parallel load. Poll for the frame instead of for the file. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): coalesce partial deltas onto one assistant item and stop painting result frames Under --include-partial-messages every stream_event frame carries its own uuid, and the final assistant frame for a block carries yet another; only message.id ties them. The translator keyed each delta by its frame uuid, so a reply painted as one bubble per delta chunk followed by a complete duplicate under the final frame's uuid. The block's first stream frame now mints the claude:(sessionId, uuid) identity, deltas coalesce onto it through the shared 60ms seam, and the final frame reconciles onto that same item. Known SDK bookkeeping no longer reaches the provider-fallback row: result subtypes are catalogued and settled by the turn lifecycle, an empty thinking block (redacted thinking) is a modeled kind, a string-content user replay is a text block, and an empty user frame paints nothing. An unmodeled result subtype or content kind still lands on the bounded fallback row. Claude-Session: https://claude.ai/code/session_01GaP5HpYQbvy2hYehVhwfEW * fix(claude): prove descendant exit at the close boundary instead of on an unref'd timer close() reported proven=true as soon as the direct child exited while the descendant sweep's SIGKILL sat on an unref'd 2 s timer, so a SIGTERM-resistant MCP server outlived the lease release. The reaper now composes the same shared primitives the Codex structured provider uses: snapshot, verified bounded descendant termination on POSIX, taskkill /T /F on Windows. The proof is false whenever descendants outlive the deadline, a retried close re-verifies the retained snapshot rather than trusting the dead root, and the raw pipe child no longer goes through the PTY job sweep it never owned a job for. Measured on macOS: a killed child of a SIGSTOPped parent stays a matching zombie row in ps, so the root is killed while verification runs rather than stopped first as the Codex non-group path does. Claude-Session: https://claude.ai/code/session_0161QFm3KVRNJKfdzWVGVNWk * feat(claude): replace the hand-rolled control plane with the SDK's native surface PR 3 of the Claude structured SDK migration removes the wire-frame scaffolding PR 2 kept, so Orca drives the SDK's typed control surface directly. Inbound permissions move from a rebuilt control_request dispatch to the SDK's canUseTool / onUserDialog callbacks. The prompt registry now carries the callback's own resolver: a decodable can_use_tool becomes a durable prompt whose answer settles the callback; a malformed one is denied without registering; the SDK's abort signal (fired on control_cancel_request, which the SDK matches and dedups itself) forgets the prompt and settles it null, and a late answer after abort finds no prompt and is refused. Closing settles every in-flight callback so no promise dangles. The claude-agent-sdk-control-bridge that rebuilt the wire frame is deleted. Outbound control maps to Query methods: interrupt() for cancel, setModel / setPermissionMode / applyFlagSettings for options, supportedModels for the model list, initializationResult() for init proof, each under Orca's own request deadline and error classification. Cancel is interrupt-receipt aware: a CLI advertising interrupt_cancel_queued_v1 gets cancel_queued in one round trip, otherwise the receipt's still_queued uuids are swept with cancel_async_message so a cancelled turn cannot spawn a later unexpected turn; older CLIs resolve no receipt. Init keeps the 10s deadline and the unauthenticated-startup guidance. Every behavior is failing-first and ablation-proven; the toggle-off import boundary and the accepted loss of unknown-control visibility rows are unchanged. Claude-Session: https://claude.ai/code/session_01Pqjduxt5G4rr9aYvtp7rNm * fix(claude): arm the descendant snapshot before stdin closes and make the tree verdict unproven by default A healthy Claude root leaves within the graceful window, and the close ladder only snapshotted descendants when the root was still alive after that window. So the common close never looked at the tree: `treeExited` stayed null, `!== false` passed it, and close() reported a proven exit with an MCP child still running. A root that died before the walk made the snapshot vacuous too. The proof is now unproven by default. The reaper holds one verdict in Orca's vocabulary (exited / live / unverifiable), assigned in exactly one place from the bounded verification, and close() returns true only on `exited`. The snapshot is armed before stdin closes, while the root can still be walked, and is verified after the root exits; a root that left before any snapshot could be armed stays unverifiable rather than vouching for descendants it never showed us. The shared verifier gains the three-way verdict behind its boolean face, and the connection reports the root and tree verdicts separately along with the child's exit status. One verification per close attempt: the retried close re-verifies, so the intra-attempt re-reap is gone from the teardown budget. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): verify the Windows tree after taskkill instead of trusting that it ran `terminateWindowsProcessTree` resolves from taskkill's callback whatever the error says, so a timeout, an access denial, a recycled root and a surviving descendant all looked identical to the reaper — which then returned a proven exit unconditionally. close() reported true and the lease was released with an MCP descendant potentially still live. The Windows branch now snapshots the root's descendants while it is alive and, after taskkill, polls a fresh process table to a bounded deadline: a row still matching by pid AND creation time is `live`, an unreadable table is `unverifiable`, and only a table with no match is `exited`. Creation time is the PID-reuse guard the POSIX path gets from ps lstart, so a descendant that denied a creation-time query is omitted rather than signalled on a bare pid. A root already observed exited is never taskkilled: `/T /F` on a recycled pid would take an unrelated tree down with it. The captured tree is tagged by platform so neither verifier can be handed the other's rows. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): release a reservation on a first-hand root exit instead of latching it into manual recovery Making close() strict about the descendant tree exposed a second defect at the same boundary. A create-time acquisition has no ownerProcess until publication, so an unproven cleanup mapped to handoffStage `manual-recovery`, and adjudication then refuses every later attach with agent_session_ownership_unknown. A user who was merely signed out, or whose --resume the CLI rejected, wedged the session id permanently. Each question now answers from its own evidence. close() is unchanged and stays strict about the tree. Separately, the lease is keyed on the root's pid and start time, so when Orca's own child handle observed that root exit and no descendant snapshot was ever admissible, the reservation is released and the CLI's exit code and stderr reach the user. A descendant observed still alive, or a root Orca never saw leave, stays unproven and keeps the reservation. The settlement records only what was observed: the released lease says the provider process exited and its descendants were not verifiable, rather than reusing the wording that claims cleanup proved no child remains. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): surface an API error a result frame reports instead of settling the turn on it The SDK models an API failure as a SUCCESS-subtype result whose `result` string is the user-facing error text, with no assistant frame behind it. The translator suppressed every catalogued result subtype as turn bookkeeping, so that turn tombstoned its lifecycle and showed the user a completed, empty reply with no sign anything had failed. Suppression is now by meaning. A result reporting a failure routes to the bounded provider-error surface, leading with the provider's own sentence and keeping the raw frame behind the row's disclosure; ordinary successful results stay off the timeline as before. A turn the user aborted also stays suppressed: its interrupt frame already says so, and its execution diagnostic would only be noise on every stop. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): drop the stream state of turns that never received their final frame Every streamed delta recorded its block's identity, latest text and checkpoint length. Only the final assistant frame removed them, so an interrupted turn left its whole accumulated reply reachable until the session was disposed, and a long session with repeated interruptions grew those maps without bound. The partial text was already journaled by the flush that precedes settlement, so the live copy was pure retention. That state now lives in its own module, named for what it does — grow a streamed block's journal row between its deltas and its final frame — and turn settlement drops every block still awaiting a final. The translator reports how many remain, which is the invariant: a settled turn leaves none. Also makes a timed-out process-table read retryable while the root is still alive. A loaded host can miss the table's one-second deadline, and latching that as "no descendants" both lost the descendant sweep and, on a busy machine, made the close ladder report unproven for a tree it never actually looked at. Only the root's death still makes a missing snapshot final. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * perf(claude): capture the Windows descendant tree from one process-table read The capture walked the descendant tree and then read the table again for the creation times the walk's projection drops. Each read is bounded in seconds and both run inside the close ladder's budget, so the second one cost the worst-case teardown three seconds for data the first read already held. The walk is now exported from the module that owns it and runs over rows the caller has already read, which is also what lets the snapshot keep the PID-reuse guard the projection cannot carry. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(pty): spend the descendant verification window instead of surrendering on one slow table read The verification abandoned the whole check the first time a process-table read missed its own one-second deadline, with seconds of its window still unspent. On a loaded host that reported a tree unverifiable without ever having looked at it, which the Claude close ladder then turned into an unproven close and a retried teardown. It also made the descendant-exit tests flake under a parallel suite run, for the same reason and with the same honest-but-premature verdict. A read that missed its deadline is now simply not an answer: the loop waits and reads again until its own deadline, and only a window that ends without a readable table reports unverifiable. This can only turn a premature verdict into one backed by evidence; it never manufactures a proof. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): never let a later failed look collapse an observed live descendant into unverifiable The reaper's single assignment site latched only 'exited', so a second reap whose table reads all missed their deadline overwrote an earlier completed verification's 'live' with 'unverifiable'. The acquisition release gate discriminates on exactly that pair, so a root exit after such a decay released the lease over a descendant that had been observed alive. The latch is now monotone in trust order: exited is final, and live is only ever raised to exited. Claude-Session: https://claude.ai/code/session_01HfdhsvSJucLw4cTZxzg2CP * fix(claude): never prove a Windows tree gone while a descendant denied identification The Windows snapshot dropped rows that denied the creation-time query, and an emptied snapshot was judged exited without any table read: a descendant Orca was refused information about was treated as one that had left. The snapshot now counts the unidentified rows it saw, and verification caps its verdict at unverifiable while any exist. Nothing is ever signalled on a bare pid, as before. Claude-Session: https://claude.ai/code/session_01HfdhsvSJucLw4cTZxzg2CP * fix(claude): classify cleanup after a first-hand exit as a root exit instead of a proven tree When the CLI died between a successful acquire and the host's commit or proof of the lease, handleExit had already removed the session, so releaseAcquisition found nothing and reported true. The attach flow then settled exit-proven with deathEvidence claiming cleanup proved no provider child remains, though the tree was never verified. The adapter now keeps the exit that removed a published session until the session is acquired again; acquisition cleanup runs that connection's close ladder and classifies its verdict exactly as a start-time failure would be, so the record reads root-exit-observed. The wire helper keeps that typed classification and its provider diagnostic instead of wrapping it as unproven, and the router gives up its owner even when the release throws. Claude-Session: https://claude.ai/code/session_01HfdhsvSJucLw4cTZxzg2CP * fix(claude): integrate SDK teardown and picker lifecycle fixes * fix(claude): preserve resume leaf and settle processless spawns * fix(claude): reacquire from persisted resume leaf * fix(native-chat): restore Claude grouped question handling * fix(claude): persist only resumable transcript leaves * fix(claude): recover structured session exits safely * fix(claude): close remaining structured session P1s * fix(claude): harden transcript branch proof * Remove superseded root fix reports * fix(windows): restore indexed descendant row walk * fix(router): forward force-close lifecycle * fix(claude): fence stale turn cancellations * fix(claude): fence cancellation after unknown dispatch * fix(claude): fence replay and option recovery races * fix(claude): block replay fallback after waiter eviction * fix(claude): fence evicted slash results * fix(claude): fence ambiguous results and restore options safely * fix(claude): scrub SDK child env and localize pending launch * fix(claude): pin transcript roots and exit recovery proofs * fix(claude): retain unproven SDK exits * fix(claude): settle retained exit before reacquire * fix(claude): resume from settled retained cursor * chore: remove tracked review artifact * fix: harden Claude SDK transport session cleanup * fix: close Claude sessions safely * fix(claude): close races with fresh child snapshots * fix(claude): fail closed on recycled child identities * fix(claude): gate root cleanup on process identity * fix(claude): fence same-second root identity reuse * fix(claude): restore the root SIGKILL fallback the identity gate took away The direct root kill goes through the handle Node owns, not through a pid: libuv drops that handle in the same turn it reaps, so the signal either reaches the process Orca spawned or reaches nothing at all. Gating it on a process-table probe therefore bought no safety and cost the tree its only fallback whenever the probe declined -- a first capture landing in the fork's own second, a recycled descendant pid voiding the snapshot, or a process table that could not be read on either platform. Identity verification stays where a bare pid is genuinely addressed: Windows `taskkill /T /F`, and the descendant sweep's own revalidation before it signals. Also stops a declined root probe from collapsing an observed `live` or `exited` descendant verdict into `unverifiable`, and stops a successful taskkill from reporting `unverifiable` because a later probe found the root correctly dead. * docs(claude): rewrap the root-kill ordering comment * Match the Claude structured launch to the terminal path's managed-account auth rules The SDK path stripped ambient Anthropic auth unconditionally, let an explicit agentDefaultEnv override beat a pinned managed account, and had no account-switch guard. Reuse the terminal preflight's own predicate and messages so both transports strip, refuse, and report identically, and cover the CLI transcript location that mobile native chat depends on. * Reach the Claude structured chat lane from the desktop UI The main process has had a complete, correctly gated Claude Agent SDK lane for a while, but no renderer ever asked for it: the launch route accepted only `codex`, and the create path was typed `agent: 'codex'` end to end. Widen both to the structured provider union that already exists (`AgentSessionHandleProvider`), and generalize the codex-named create path instead of adding a Claude twin beside it. The pending-launch registry is now keyed by agent as well as workspace — a shared key handed a second caller the first agent's intent, so a Claude and a Codex launch in one worktree collided. Windows, per agent. Codex's client-side win32 refusal is deliberate and settled elsewhere, so it stays exactly as it was. Claude's answer is no longer guessed from the client's platform: a structured session fences its provider child on that child's process start time, and only the executing host knows whether it can read one. `agentSession.createSupport` already answers precisely that, per agent, and had no renderer caller — so the Claude create path asks it before creating and turns a "no", or a probe it cannot get answered, into the definitive refusal the launch fallback already handles. Fail closed either way. That refusal mapping also closes a real gap: the host reports an unsupported location by throwing `structured_agent_session_unsupported`, which reaches the client as a transport rejection rather than a refusal envelope, so `StructuredAgentSessionCreateRefusalError` never fired. The launch would retry the create, strand itself in `visibilityUnknown`, run no legacy fallback, and show an error toast. Close a fail-open hole while Claude and win32 become reachable: `create` with a client-supplied location, and `ensure`, both skip the worktree-resolving support check. They now ask the executing host the same question directly, so a host that cannot fence a provider child no longer creates one on a client's say-so. Also deletes `structured-agent-session-provider-routing.ts`, a duplicate of `structured-agent-session-provider-support.ts` with no importers. WSL, SSH and paired hosts, floating workspaces, draft prompt delivery, explicit TUI customization and initial session options all keep refusing; folder workspaces keep working. * P1-1: make the structured Claude auth policy required and testable The optional dep plus a {stripAuthEnv:false} fallback meant a dropped wiring under-stripped silently. Required at all three hops, asserted at install time for the @ts-nocheck caller, and the settings-to-policy mapping is now a named tested function. * P2-3: mobile's default Claude transcript root must follow CLAUDE_CONFIG_DIR session-file-resolver's default ignored the variable the pinned account home follows, so a CLAUDE_CONFIG_DIR launch wrote one tree and mobile read another. The Task-4 test now resolves with no root override (mobile's own call) and checks the answer against the root the CLI itself reports, instead of mirroring the code under test's own expression. * P2-1/P2-2/P3: close the teardown window, join the live-auth gate, align the refusal P2-1: a switch beginning inside the acquire teardown left a dead chat and no replacement. Past that point the launch waits the swap out and refuses only if it never settles; the entry guard still refuses outright, because nothing is torn down there yet. P2-2: structured children now hold the same OAuth-refresh gate a Claude PTY does, so a managed refresh cannot rotate the token out from under a live turn. P3: the refusal now matches the strip it guards (case-folded on win32, presence not truthiness), and the dead structured-to-TUI builder states its auth policy instead of silently signing a system-auth user out. * Make the live-auth gate tests independent of sibling connection teardown order * Do not offer structured Claude under a WSL-only managed account Structured Claude launches against the ambient Claude config, which the account service keeps in sync with the selected HOST account. A WSL-bound managed account lives inside the distro and is never synced there, so on Windows a structured session would authenticate as whatever the ambient identity happens to be while the UI names the WSL account — the user is told one identity and given another. That was unreachable only because nothing offered structured Claude on win32. Enabling it makes it reachable, so gate it here rather than patching the auth layer: refuse the structured path when the active managed Claude account is WSL-bound, and let the terminal-backed path — which resolves the account per runtime — handle that account shape. The answer rides the agentSession.createSupport seam the renderer already consumes, so no new capability and no renderer knowledge of account internals. A create the host declines becomes the definitive refusal the launch fallback already turns into a legacy native chat tab, with no error toast. Unknown answers refuse. An install with no managed accounts claims no identity and is fine, but an active selection that cannot be resolved — or account state that cannot be read at all — is not evidence that the ambient identity is right. Claude only. Codex resolves its account through a different path and its createSupport answer is untouched, as is every Codex routing decision. * Read the structured Claude account gate through the auth policy's accessor The gate resolved the active account from the account-service snapshot's runtime map; the auth policy resolves it with getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }). Those are two sources and two resolution rules, and they disagree on a legacy settings blob that carries the selection only in the flat activeClaudeManagedAccountId: the accessor falls through to it, a direct read of the runtime map does not. The gate would then refuse a launch the policy would have run under host-1 — and in the mirror case a session could be admitted under a policy computed from a different account than the gate approved. Read the same settings through the same accessor so agreement is structural rather than coincidental, and drop the controller accessor that existed only to reach the snapshot. No behaviour change for any state both already agreed on; Codex is untouched. * Round-3 review fixes: N-1 empty-value regression, N-2 gate leak window, N-4 lost history N-1: my presence-based conflict predicate refused a terminal launch that works today. 'ANTHROPIC_API_KEY=' is how a user blanks a variable and the settings pipeline preserves that empty value; an empty override cannot beat the pinned account and the strip removes the name anyway. Back to truthiness for the value, keeping the win32 case folding. N-2: enter the live-auth gate only after the exit/close handlers that release it, so no throw in between can leave an entry nothing reconciles. N-4: the Claude transcript resolver searches config-dir-then-default and de-dupes, matching the Codex sibling in the same file, so adopting CLAUDE_CONFIG_DIR no longer hides history written before it. * Run the managed-account gate on every Claude acquisition, not just create createSupport gates the create path, but a session's account state can change while it lives. A reacquire after an unexpected child exit re-resolves the launch and re-derives auth, with nothing re-checking the gate — so a session created while supported could come back up in the refused shape. With the strip predicate keyed on there being an active non-WSL account, the WSL-only user's normalized steady state (accounts exist, none active) does not strip, and that reacquire reaches the child with ambient auth while the UI names the account. Gate at resolveLaunch, the one choke point every acquisition passes through, refusing with the pre-spawn error the caller already handles. Same predicate as create-time, now sharing one settings reader so the two cannot drift. Claude only; Codex resolves its account on a different path and is untouched. The runtime class that wires this does not typecheck its own `this` calls — a missing hookup compiles clean — so the wiring is pinned behaviourally rather than trusted to the compiler. * Move the structured Claude gate out of the @ts-nocheck runtime files Both call sites of the managed-account gate sat in files whose first line is `// @ts-nocheck`, so neither was typechecked: three arguments to a one-argument function plus an undeclared identifier compiled clean. New auth-identity decision logic had no compiler behind it. Move the verdict into a checked module that takes the two facts the runtime owns — the adapter's answer and a settings getter — and decides. The runtime class now only forwards. Move the gate reader's construction into the checked installer too, so the nocheck file passes a plain settings closure and never names a gate symbol. Every reference to the gate predicate and its reader now lives in a checked file, so the ablation that used to pass silently is a compile error at both the create-support and reacquire sites. Removing the file-level @ts-nocheck is a separate, larger job and is not attempted here. * Derive the gate test's auth policy from the settings under test A hardcoded stripAuthEnv asserts a gate/policy pairing production cannot produce, and false additionally lets launch.env inherit the runner's real process.env. Derive via claudeStructuredAuthPolicyForSettings instead: the gate settings type is the same Pick the policy takes, and both resolve the account through getSelectedClaudeAccountIdForTarget. * Pin the absent-vs-empty distinction in the managed-account gate An empty claudeManagedAccounts array is a real answer: the user has no managed accounts, nothing claims an identity, and the ambient path is legitimate. A readable settings object with no such field is settings we failed to parse — the same unknown as unreadable — so it refuses. The two are one character apart in the code and the difference is invisible without the reasoning, so record it at the branch and pin both sides. The test fails under the obvious "consistency fix" of treating a missing field as empty. * fix(claude): keep command queue bookkeeping out of the transcript Claude Code 2.1.258 emits a `command_lifecycle` frame for every uuid-stamped command it starts, completes or cancels. The frame carries a command uuid and a state and no content, and the CLI keeps it out of its own transcript -- but it is absent from the SDK's SDKMessage union and so from Orca's frame catalogue, where an uncatalogued kind defaults to a substantive row. Every structured turn therefore painted raw JSON rows into the user-visible transcript. Catalogue it and disposition it as status chrome. The unknown-kind default stays `timeline-substantive`: a kind we have never seen is likelier to carry content than to be chrome, and a visible row we can catalogue later beats content we silently dropped. A lifecycle state that reads as a failure still surfaces, because the payload error check in `classifyProviderFrame` outranks the catalogue. * fix(claude): let a re-walked descendant become eligible for the forced sweep A descendant first observed by a capture inside its own birth second could never be SIGKILLed: `ps lstart` is second-resolution, so that capture cannot rule out a pid recycled later in the same second, and the merge pinned each retained row to the boundary of the walk that first saw it. SIGTERM-resistant children forked in that window were signalled and then never escalated -- they survived close, quit and restart, reparented to init, and had to be killed by hand. Advancing that boundary on any later capture would be unsound: a later capture matching pid, pgid and start-second is exactly what an impostor would also show. But a capture is not a match -- it is a fresh ppid walk from a root Node pins through its own handle, so a row it re-derives is proved ours at that instant without appealing to its start time. Chain the fence from there instead, and take that walk at the close boundary while the root certainly still lives: the root may leave inside the grace window, and the post-timeout refresh never runs. A row absent from the later walk still keeps its earlier boundary, and a row no walk has ever re-derived in a later second is still never escalated. * Treat an absent managed-account list as empty, not as unreadable An empty claudeManagedAccounts array and a missing one are the same answer: this user has no managed Claude accounts, so nothing claims an identity and ambient auth is the truth. Refusing on absence strands any profile that simply never wrote the key, and it disagrees with the auth policy, whose own predicate takes `(accounts ?? [])` for exactly this reason. Only settings that cannot be READ stay unknown, and those still refuse — as do a WSL-bound active account and a selection naming an account the list does not explain. The earlier reasoning treated a missing field as settings we failed to parse. That conflated "not present" with "not readable"; only the second is unknown. * Support structured Claude when accounts are registered but none is selected Registered-but-deselected Claude accounts were refused, which is behaviourally identical to having no accounts at all: the auth policy does not strip, ambient auth is the truth, and the UI names no host identity. A user who deselected their accounts silently got legacy chat with nothing explaining why. Nothing selected for the host runtime is two states the settings cannot tell apart after the fact, because pruneInvalidClaudeRuntimeSelection empties the host slot and persists null in the second one: honest deselection -> ambient auth, UI names nothing -> SUPPORTED the WSL-only steady state -> ambient auth, UI names the WSL account -> REFUSED The presence of any WSL-bound account in the list decides. Simplifying this to "none active -> supported" re-opens the auth-identity misrepresentation, so the tests fail loudly on exactly that: five of them, across the unit rule and the createSupport path. * Stop treating an unanswerable create-support probe as a refusal A worktree is not resolvable for a beat after createWorktree resolves, so a probe fired immediately after creation fails the RPC with selector_not_found instead of answering. The catch collapsed that into `supported = false`, so the composer refused and quietly built a terminal session — the gate never said no, it was never asked successfully. Elapsed time was the only input that decided whether a Claude launch went structured. "Could not answer" and "answered no" are different states and only the second is a verdict. Retry while the host cannot yet resolve the selector, with a bounded backoff that covers the measured window with margin, and keep refusing on the first ask for everything else. Fail-closed is unchanged: a probe that still cannot be answered when the budget is spent refuses. The retry is narrowed with the shared error-code matcher, which classifies a token that transports re-wrap into a longer message without matching prose that merely mentions it. Codex never probes, so this race has never been able to refuse a Codex launch — the race itself is identical for it. Recorded at the early return, because whoever gives Codex a probe inherits the bug. * fix(claude): fence the forced sweep on re-derivation, not on lstart's second A descendant forked in the same wall-clock second as every walk that sees it was signalled with SIGTERM and then never escalated, so a SIGTERM-resistant child survived tab close, app quit and a full relaunch. Two children of one parent 96ms apart across a second boundary took opposite paths. The leak predates this branch: it reproduces with the change reverted. `ps lstart` has one-second resolution, so a walk landing inside a row's birth second can never rule out a pid recycled later in that same second. But a walk is not a match: a ppid walk only reaches what the root actually parents, and the root is pinned by Node's own handle, so a row the walk re-derived is ours whatever second it was born in -- a stranger would have to have been forked into our tree, and then it is not a stranger. Fence the escalation on that. Rows a merge retained from an earlier walk are not re-derived and still answer to the start-time fence, which remains correct for them. Scoped to callers that revalidate identity before signalling, which is the Claude close path. Codex teardown reaches this same verifier and is unchanged; the argument holds there too, but widening it is its own deliberate change. Also reverts two changes from the previous attempt at this leak. Advancing the capture boundary on a later walk is inert once the sweep fences on re-derivation -- both key on the same set of rows, so the new term short-circuits for exactly the rows whose boundary it advanced. The extra ladder refresh was a duplicate full process-table read: close() already awaits tree.refresh() immediately before proveClaudeChildExit, on the only path that reaches it. Known property: the kill lands roughly a grace window after the walk that proved membership, so a pid recycled inside that gap could in principle be signalled. It is bounded -- matchingSnapshotRows already requires the live row to carry the same start-second and pgid, so an impostor must be born in the remainder of that one second, land on that exact pid, and sit in the same process group, and it has already received the unfenced SIGTERM from the same loop. * Run the Claude structured integration suite as a runtime client The suite exercises agentSession.* for Claude, not the mobile surface: nothing in it asserts anything mobile-specific and its sibling integration suites use 'runtime'. Mobile now additionally requires the experimental structured-chat setting, which structured-agent-session.test.ts pins in both states, so the stale 'mobile' fixture was claiming coverage it never had. * fix(claude): report effort from get_settings, which is the only frame that has it The composer's Effort pill rendered blank in every structured session. This is not a missing source: the publication reads `effortLevel` off the `system/init` frame, and that frame has never carried an effort of any kind, while the correct value is already fetched at acquisition and thrown away on the auth diagnostic. Verified two ways -- a live get_settings probe against Claude Code 2.1.258, and the shipped binary's own init frame construction, which lists `model` and no effort. So `reportedOptions.effort` was always empty, the options reader dropped the key, and the pill had no value. Model survived only because `currentModelId()` has a fallback chain. The get_settings call acquisition already makes reports the session's current effort as `effective.effortLevel`; pass that into the publication instead. Selecting an effort already worked, so this is the arrival value only. The legacy PTY path is unaffected and must not be "fixed" to match: it reads its effort by parsing the startup banner (`CLAUDE_MODEL_EFFORT` in src/renderer/src/components/native-chat/claude-terminal-session-options.ts), which is why it shows a value where the structured path does not. Also removes the fixture that hid this: the fake init frame invented `effortLevel: 'high'`, a field the CLI does not send, which is why every gate stayed green over a value that is always empty in production. The fixture's get_settings now returns the real {applied, effective, sources} shape instead of a bare `{env: {}}`, so the two adapter tests that asserted an effort keep asserting it through the path production actually uses. The reader returns null rather than defaulting: an effort nothing measured would repeat the fixture's mistake, and a blank pill is the honest degradation if the provider ever renames the key. * fix(claude): only record an effort the child confirms it adopted apply_flag_settings answers `success` for an effort it then ignores. Measured against Claude Code 2.1.258: applying `bogus-effort-xyz` returns subtype "success" with no error while `applied.effort` stays at its previous value, and a valid `low` moves it. The option write treated the absence of a throw as adoption and recorded the requested value unconditionally, so Orca would show and persist an effort the child was not using, with nothing anywhere reporting a problem. Read the effort back after applying it, through the same reader the arrival value uses, and reject when the child reports a different one. A readback that could not be taken is not evidence of a refusal -- the apply itself succeeded -- so it still records; only a readback that disagrees rejects. Not reachable from today's picker, which offers catalog values only, but the CLI's effort catalog is server-delivered and has changed before, so a retired id would otherwise become a pill confidently displaying a setting that never took. * test(claude): assert the effort contract against the real binary The blank pill survived every gate because the only tests that touched it were fixture-backed, and the fixture invented the field. A test that pins the shape we read cannot catch the provider renaming the key, which is the failure mode that produced this defect. Asserts both halves against a live authenticated CLI: that no frame it publishes carries an effort at all, and that the session's current effort arrives through get_settings. Which frame proves the session varies by host -- this machine proves it with a SessionStart hook rather than a system/init frame -- so the negative half asserts over every published frame rather than picking one. Skips with the rest of the file when no authenticated CLI is present. * fix(claude): stop the synthesised content-part kinds leaking into the transcript Sending an image put a bare `claude · message:user:content:image` row between the user's bubble and the answer. Two causes, and only the second is a family. An image part counted as modelled only when `source.type === 'url'`, but claudeDispatchMessageContent sends a local attachment as a base64 source and the CLI replays that shape back, so every attached image was classified unmodelled. Accept the base64 and file sources Orca itself sends. The family is the real defect. `message:<role>:content:<type>` kinds are synthesised at runtime from whatever `part.type` arrives, so unlike the top-level frame catalogue they can never be enumerated ahead of time -- the `?? 'timeline-substantive'` default then prints the synthesised name at a user who cannot act on it. That default is right for top-level frames, where "substantive" means show the frame; here it meant show our own vocabulary, which drops the content AND leaks the opcode. So an unrenderable part now renders a sentence saying exactly that, with the kind and payload still on the row's disclosure. A part that carries its own readable sentence keeps it -- the placeholder is a fallback, not an override. An unknown future part type is therefore visible, never silently dropped and never printed as a kind: the same principle as the effort readback, which records only what the provider confirms. * Declare agentSession.requestHandoff on the cross-version wire surface The manifest is a ratchet for cross-version reachability, so the method is declared with real HandoffParams rather than counted. requestHandoff is capability-gated through requireStructuredHost and has no client caller, so declaring it is the whole of the change. Also model two host capabilities the harness omitted: the stub host's supportsCreate, and the fake adapter's, without which adapterSupportsCreate falls through to a supportsLocation the fake also lacks. Every ensure was refused for the harness's silence rather than for its location. * Gate structured Claude session tabs on the client capability that names them The Claude structured lane deleted the projection's `agent !== 'codex'` filter and added CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY in the same commit, but never wired the constant to anything. Paired clients then received agent-session tabs for Claude, which no shipped client renders -- mobile's resolveMobileNativeChat returns null for every agent but codex, so the row listed and selected into a pane with neither chat nor terminal. Restore the filter behind the declared capability instead of the bare agent name. No client advertises it yet, so this matches main's behaviour today and becomes a negotiation a future client can opt into. * Confirm the structured Claude model against the model the CLI reports set_model answers success for any string, including a model it cannot resolve — the failure only surfaces when the turn runs — and get_settings reports the settings-file model, not the session's. The init frame that opens each turn is the only channel carrying the adopted model, so keep the session's reported model current from it instead of reading it once at acquisition. Also stop rejecting an effort the readback cannot represent: max is session-scoped and excluded from the persisted effortLevel, so a readback reporting the level underneath it is an absence of evidence, not a refusal. * Clear the session-option hedge when the provider confirms the value The pill claimed every option was unconfirmed for the life of the session: the renderer recorded each write as dispatched and nothing ever moved it, so a model the CLI had already reported back still read as unconfirmed. Carry the provider's own confirmation to the surface. Main reports which option ids the provider named rather than merely accepted, and the client re-reads options as a turn changes, because the frame that opens a turn is where the adopted model arrives. A value the provider has not reported stays hedged, including an effort whose readback could not be taken. The confirmed list is optional on the wire: a host that predates it sends nothing and the client keeps hedging, which is the behaviour it had. * Keep the model report current across an acquisition fence bump * Show the picked session-option value and let the provider report correct it The pill showed a "not confirmed" second tooltip line for any value we had sent but not yet seen reported back. Nothing acts on it, and for the PTY lane it was permanent — that transport has no report channel. The pill now shows the picked value immediately and the provider's per-turn report corrects it when the two disagree; a newer local write still outranks a report that precedes it. `dispatched` stays as a provenance member rather than collapsing into `applied`: it is produced independently by the PTY lane, and it is where the `confirmed` wire field lands, which would otherwise be unobservable. Effort keeps its readback and its rejection path. That matters more now, not less: with the hedge gone the rejection is the only user-visible failure signal on this surface, so a spurious one would be the loudest bug here. Skipping the readback for an effort the settings response structurally cannot echo is what prevents it — the response carries the persisted level, so reading it back for a session-scoped value would report the level underneath and fail a valid write. * Hedge a session-option value only when the terminal transport sent it Both lanes emit `dispatched`, so it could never say which one produced a value. The descriptor now carries the transport that built it, set once in the shared snapshot builder from a parameter that is required rather than defaulted — the builder is the only place a descriptor is constructed, so a new producer has to name its lane or fail to compile. The structured lane confirms every value from the provider's own per-turn report, which makes the hedge transient noise there. The terminal lane can only learn an outcome by parsing the screen back, and only for Claude: every other agent's `dispatched` value stays unconfirmed for the life of the session, so the line is the only signal that we sent something we never saw land. * Refuse an effort the session's model advertises no control for * Refuse tab mutations on a Claude row the client never negotiated The branch added a case asserting a client advertising only agent-session.structured.v1 may mutate a claude row. That is the same ungated behaviour the projection gate removes, encoded a second time — mutation authorization reads the projection, so hiding the row refuses the write. Assert that contract instead, and add the positive case for a client that does negotiate Claude rows. * Resolve the Claude session's current model in one place so the effort guard and the pill agree * Record an effort the child did not adopt instead of refusing the write apply_flag_settings answers success for an effort it then ignores, so the readback exists to detect that. Refusing on it made the detection a veto, and a veto is only correct if the readback can never be wrong about which model is current -- which it was, twice. The pre-flight guard already refuses a level the model advertises no control for, so the veto guarded a door that is now locked upstream. Keep the detection, drop the refusal: a disagreement records the child's own answer and omits the option from confirmed, so main stops vouching for a value the provider rejected without blocking the user's write. * Stop a slow whole-machine ps from being read as an absent process `ps -axo ...command=` pays a per-pid argv read: measured 1.15s for 1,948 processes (0.03s without `command=`), and CPU contention stretched the same capture to 6.0s. Two budgets sized for a cheap look then misreport a readable machine. The reader's 3s ceiling killed 6 of 20 consecutive captures at load 27, so every consumer answered "unverifiable" about a table it could read. Raise it to 15s, and stamp the capture instant at ps START so `capturedAgeMs` is the upper bound its contract promises -- a 6s capture used to report itself as freshly taken, understating staleness against a 5s kill gate. The TTL keys on completion so a slow capture still coalesces instead of forking ps per caller. `readStructuredTuiProcessIdentity` then spent its whole 5s wait inside one capture and concluded "no exact child" after a single look taken before the child existed (observed landing at ~3.5s). Absence needs a look that did not race the spawn, so require two captures before the deadline can end the loop. Both surfaced by the real-binary Claude TUI resume test, which failed ~1 in 5 under load; 14/14 now, 8 of those runs containing a capture the old 3s budget would have killed. * Let the desktop renderer negotiate Claude structured tabs The paired-client gate hides agent-session rows an agent the client cannot render. The desktop renderer's own IPC dispatches as clientKind 'runtime' advertising only agent-session.structured.v1, so the gate hid Claude rows from the surface this feature ships on. It renders them; it should say so. * Stop a slow process table from silently blinding every freshness gate Stamping `capturedAgeMs` at ps START made the number honest, and honest broke both consumers that read it. `ps -axo ...command=` measured 2.5-9.0s on an idle 2,002-process laptop and 4.0-18.6s at load 46, so the age it now reports lands past every budget: `planRelayPtySweep` refuses the stop as "too old", and the renderer's `admitRemoteForegroundEvidence` refuses the record outright. That second one is the expensive half and was outside the diff -- a refusal bumps `consecutiveInspectionErrors`, the poll scheduler backs off to its 10s floor, and agent-completion detection stops for the pane. The subsystem went blind on exactly the loaded hosts the honest stamp was meant to serve. The evidence-publishing read now gives up at 1,200ms instead of waiting out `PS_TIMEOUT_MS`. It is one budget for one question: these consumers ask whether an observation describes NOW, and past this it does not -- a late answer is refused by the age gate anyway, having first blocked a polled path for the whole capture, so a prompt `unverifiable` is both the truthful verdict and the cheap one. Both relay call sites already produce it from a rejection, and an admitted `unverifiable` costs a poll where a refusal costs the cadence. Identity proof keeps the full 15s through `getFreshProcessTableSnapshot`, because it asks whether a process EXISTS and must never read slow as absent. The budget bounds the wait, never the capture: the reader coalesces, so an abandoned wait leaves its capture running to fill the cache rather than forking a second whole-machine `ps` on the host that can least afford one. 1,200ms is bracketed rather than picked. The floor is the capture's own cost -- `command=` measured 1.15s for 1,948 processes on an idle host, and a budget under that answers `unverifiable` about a machine nobody is straining. The ceiling is the consumer's: 2,000ms, less the 500ms a TTL-shared capture may already have aged, leaves 1,500ms, and transit takes the rest. That ceiling only fits once the capture stops being charged twice. `ps` runs inside the RPC round trip, so its duration is already in `receiveDelay`, and `capturedAgeMs` is that same duration on the host's clock; summing them halved the budget this gate grants a host from ~2.0s of `ps` to ~1.0s, which is why a 1.2s capture arriving at 1.3s read as 2.5s old and was refused. Admission now takes the larger of the two. The sweep's gate keeps its sum, which is correct there: `evidenceAgeSinceListingMs` is stamped after the listing ARRIVES, so it measures planning time and overlaps nothing. A stated limit rather than an assumed one: 15s is not proven sufficient for identity proof. The same capture reached 18.6s at load 46, so that path can still time out and answer "no exact child" about a host it simply could not read in time. Narrowing it needs a cheaper question than a whole-machine argv read, not a larger number. The one test guarding this field could not fail. `beginPtyHandlerTest` installs fake timers, so `Date.now()` is frozen, the real reader reports exactly +0, and `0 <= 500` held identically for a hardcoded zero, for completion-stamping and for start-stamping -- while the real reader on that host returns thousands of ms. It now drives a measured age in and asserts the handler publishes it rather than restamping; that the reader MEASURES it correctly stays pinned separately, against a controllable clock. Both consumers get boundary coverage either side, and each new gate was ablated red before it went green. * Keep the compatibility fields off the capture the budget just abandoned inspectProcess falls back to processHasChildren and listProcesses to getForegroundProcessName, and both read the same TTL-shared capture with no budget of their own. On a slow host they joined the in-flight capture the budgeted evidence read had just given up on, so the call still blocked for the full 6-18s and the budget bought nothing -- once for inspectProcess and once per managed PTY for listProcesses. Use the degraded answers those helpers already give for an unreadable table, reached promptly. pty.hasChildProcesses keeps its unbudgeted fresh probe: it is a one-shot destructive gate that can afford to wait. --------- Co-authored-by: Merge Sim <merge-sim@local> Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
Merge Sim
parent
264c9ed8d2
commit
a65332a8bd
@@ -11,7 +11,12 @@ import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain', 'translateSearchKeyword'])
|
||||
const LOCALIZATION_FUNCTION_NAMES = new Set([
|
||||
't',
|
||||
'translate',
|
||||
'translateMain',
|
||||
'translateSearchKeyword'
|
||||
])
|
||||
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
||||
const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
|
||||
export const LOCALIZATION_SOURCE_ROOTS = [
|
||||
|
||||
@@ -41,6 +41,7 @@ const MODEL_DESCRIPTOR: SessionOptionDescriptor = {
|
||||
]
|
||||
},
|
||||
valueSource: 'reported',
|
||||
transport: 'catalog',
|
||||
settable: true
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ const EFFORT_DESCRIPTOR: SessionOptionDescriptor = {
|
||||
]
|
||||
},
|
||||
valueSource: 'dispatched',
|
||||
transport: 'catalog',
|
||||
settable: true
|
||||
}
|
||||
|
||||
@@ -66,6 +68,7 @@ const FAST_MODE_DESCRIPTOR: SessionOptionDescriptor = {
|
||||
category: 'mode',
|
||||
kind: { type: 'boolean', currentValue: false },
|
||||
valueSource: 'reported',
|
||||
transport: 'catalog',
|
||||
settable: true
|
||||
}
|
||||
|
||||
@@ -227,6 +230,7 @@ describe('MobileNativeChatSessionOptionPickers', () => {
|
||||
...MODEL_DESCRIPTOR,
|
||||
kind: { type: 'select', choices: [] },
|
||||
valueSource: 'unknown',
|
||||
transport: 'catalog',
|
||||
action: { type: 'agent-picker' }
|
||||
}
|
||||
])
|
||||
@@ -236,6 +240,46 @@ describe('MobileNativeChatSessionOptionPickers', () => {
|
||||
expect(invokeAction).toHaveBeenCalledWith('model')
|
||||
})
|
||||
|
||||
// The terminal transport can only learn the outcome by parsing the screen back,
|
||||
// so the sheet admits the value is unconfirmed; the structured transport reports
|
||||
// it every turn, which makes the same caption noise there.
|
||||
it.each([
|
||||
{ transport: 'catalog' as const, caption: true },
|
||||
{ transport: 'agent-session' as const, caption: false }
|
||||
])('captions a dispatched value only on the terminal transport', async (scenario) => {
|
||||
mount([
|
||||
MODEL_DESCRIPTOR,
|
||||
{ ...EFFORT_DESCRIPTOR, valueSource: 'dispatched', transport: scenario.transport }
|
||||
])
|
||||
await act(async () => pill('Model').props.onPress())
|
||||
await act(async () => rowByText('Effort').props.onPress())
|
||||
const captions = renderer!.root
|
||||
.findAll((node) => node.type === 'Text')
|
||||
.filter(
|
||||
(node) =>
|
||||
(node.props as { children?: unknown }).children === 'Sent to the agent — not confirmed'
|
||||
)
|
||||
expect(captions.length > 0).toBe(scenario.caption)
|
||||
})
|
||||
|
||||
it.each(['catalog', 'agent-session'] as const)(
|
||||
'does not caption a reported value on the %s transport',
|
||||
async (transport) => {
|
||||
mount([MODEL_DESCRIPTOR, { ...EFFORT_DESCRIPTOR, valueSource: 'reported', transport }])
|
||||
await act(async () => pill('Model').props.onPress())
|
||||
await act(async () => rowByText('Effort').props.onPress())
|
||||
expect(
|
||||
renderer!.root
|
||||
.findAll((node) => node.type === 'Text')
|
||||
.some(
|
||||
(node) =>
|
||||
(node.props as { children?: unknown }).children ===
|
||||
'Sent to the agent — not confirmed'
|
||||
)
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('locks the pills while the agent is working', () => {
|
||||
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true)
|
||||
expect(pill('Model').props).toMatchObject({ disabled: true })
|
||||
|
||||
@@ -3,9 +3,10 @@ import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from '
|
||||
import { ChevronLeft, X } from 'lucide-react-native'
|
||||
import { BottomDrawer } from '../components/BottomDrawer'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionValue
|
||||
import {
|
||||
sessionOptionDispatchUnconfirmed,
|
||||
type SessionOptionDescriptor,
|
||||
type SessionOptionValue
|
||||
} from '../../../src/shared/native-chat-session-options'
|
||||
import {
|
||||
mobileModelPillLabel,
|
||||
@@ -119,7 +120,7 @@ export function MobileNativeChatSessionOptionPickers({
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{activeDescriptor.valueSource === 'dispatched' ? (
|
||||
{sessionOptionDispatchUnconfirmed(activeDescriptor) ? (
|
||||
<SessionOptionCaption>Sent to the agent — not confirmed</SessionOptionCaption>
|
||||
) : null}
|
||||
{reason ? <SessionOptionCaption>{reason}</SessionOptionCaption> : null}
|
||||
|
||||
@@ -168,7 +168,8 @@ export function useMobileNativeChatSessionOptions(args: {
|
||||
models: activeModels(catalog, record),
|
||||
record,
|
||||
mode: 'live',
|
||||
modelLabel: 'Model'
|
||||
modelLabel: 'Model',
|
||||
liveTransport: 'catalog'
|
||||
})
|
||||
}, [agent, catalog, scopeKey, version])
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.251",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
|
||||
Generated
+117
-16
@@ -122,6 +122,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@anthropic-ai/claude-agent-sdk':
|
||||
specifier: 0.3.251
|
||||
version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)
|
||||
'@electron-toolkit/preload':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(electron@43.4.1(supports-color@7.2.0))
|
||||
@@ -535,6 +538,23 @@ packages:
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk@0.3.251':
|
||||
resolution: {integrity: sha512-DqSi8mH2tQYRlVV0G+lJnQ/WbjJZ/a+8cJ3vPuYoqh8esIIvXHm1ZOXV1UPGsFYRnbBytEoiSGitguEXd+sQ+Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
'@anthropic-ai/sdk': '>=0.93.0'
|
||||
'@modelcontextprotocol/sdk': ^1.29.0
|
||||
zod: ^4.0.0
|
||||
|
||||
'@anthropic-ai/sdk@0.122.0':
|
||||
resolution: {integrity: sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -2628,6 +2648,9 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
'@stablyai/playwright-base@2.1.14':
|
||||
resolution: {integrity: sha512-/iAgMW5tC0ETDo3mFyTzszRrD7rGFIT4fgDgtZxqa9vPhiTLix/1+GeOOBNY0uS+XRLFY0Uc/irsC3XProL47g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4493,6 +4516,9 @@ packages:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
fast-sha256@1.3.0:
|
||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||
|
||||
fast-string-truncated-width@3.0.3:
|
||||
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
|
||||
|
||||
@@ -5039,6 +5065,10 @@ packages:
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json-schema-to-ts@3.1.1:
|
||||
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
@@ -6425,6 +6455,9 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
standardwebhooks@1.1.1:
|
||||
resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
|
||||
|
||||
stat-mode@1.0.0:
|
||||
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -6608,6 +6641,9 @@ packages:
|
||||
truncate-utf8-bytes@1.0.2:
|
||||
resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==}
|
||||
|
||||
ts-algebra@2.0.0:
|
||||
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
|
||||
|
||||
ts-dedent@2.2.0:
|
||||
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
|
||||
engines: {node: '>=6.10'}
|
||||
@@ -7007,6 +7043,16 @@ packages:
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
ignoredOptionalDependencies:
|
||||
- '@anthropic-ai/claude-agent-sdk-darwin-arm64'
|
||||
- '@anthropic-ai/claude-agent-sdk-darwin-x64'
|
||||
- '@anthropic-ai/claude-agent-sdk-linux-arm64'
|
||||
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl'
|
||||
- '@anthropic-ai/claude-agent-sdk-linux-x64'
|
||||
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl'
|
||||
- '@anthropic-ai/claude-agent-sdk-win32-arm64'
|
||||
- '@anthropic-ai/claude-agent-sdk-win32-x64'
|
||||
|
||||
snapshots:
|
||||
|
||||
'@adobe/css-tools@4.5.0': {}
|
||||
@@ -7016,6 +7062,19 @@ snapshots:
|
||||
package-manager-detector: 1.6.0
|
||||
tinyexec: 1.1.2
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk@0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.122.0(zod@4.5.4)
|
||||
'@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.5.4)
|
||||
zod: 4.5.4
|
||||
|
||||
'@anthropic-ai/sdk@0.122.0(zod@4.5.4)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
standardwebhooks: 1.1.1
|
||||
optionalDependencies:
|
||||
zod: 4.5.4
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
@@ -7669,6 +7728,28 @@ snapshots:
|
||||
dependencies:
|
||||
'@chevrotain/types': 11.1.2
|
||||
|
||||
'@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4)':
|
||||
dependencies:
|
||||
'@hono/node-server': 2.1.0(hono@4.13.0)
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.6
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.8
|
||||
express: 5.2.1(supports-color@7.2.0)
|
||||
express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0))
|
||||
hono: 4.13.0
|
||||
jose: 6.2.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 4.5.4
|
||||
zod-to-json-schema: 3.25.2(zod@4.5.4)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 2.1.0(hono@4.13.0)
|
||||
@@ -7679,8 +7760,8 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.8
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.5.2(express@5.2.1)
|
||||
express: 5.2.1(supports-color@7.2.0)
|
||||
express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0))
|
||||
hono: 4.13.0
|
||||
jose: 6.2.3
|
||||
json-schema-typed: 8.0.2
|
||||
@@ -8917,6 +8998,8 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.5.4)':
|
||||
dependencies:
|
||||
'@playwright/test': 1.59.1
|
||||
@@ -9923,7 +10006,7 @@ snapshots:
|
||||
|
||||
bluebird@3.7.2: {}
|
||||
|
||||
body-parser@2.3.0:
|
||||
body-parser@2.3.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 2.0.0
|
||||
@@ -10779,15 +10862,15 @@ snapshots:
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
|
||||
express-rate-limit@8.5.2(express@5.2.1):
|
||||
express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
express: 5.2.1(supports-color@7.2.0)
|
||||
ip-address: 10.4.0
|
||||
|
||||
express@5.2.1:
|
||||
express@5.2.1(supports-color@7.2.0):
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.3.0
|
||||
body-parser: 2.3.0(supports-color@7.2.0)
|
||||
content-disposition: 1.1.0
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
@@ -10797,7 +10880,7 @@ snapshots:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 2.1.1
|
||||
finalhandler: 2.1.1(supports-color@7.2.0)
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
merge-descriptors: 2.0.0
|
||||
@@ -10808,9 +10891,9 @@ snapshots:
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.15.2
|
||||
range-parser: 1.2.1
|
||||
router: 2.2.0
|
||||
send: 1.2.1
|
||||
serve-static: 2.2.1
|
||||
router: 2.2.0(supports-color@7.2.0)
|
||||
send: 1.2.1(supports-color@7.2.0)
|
||||
serve-static: 2.2.1(supports-color@7.2.0)
|
||||
statuses: 2.0.2
|
||||
type-is: 2.1.0
|
||||
vary: 1.1.2
|
||||
@@ -10831,6 +10914,8 @@ snapshots:
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
fast-sha256@1.3.0: {}
|
||||
|
||||
fast-string-truncated-width@3.0.3: {}
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
@@ -10871,7 +10956,7 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
finalhandler@2.1.1:
|
||||
finalhandler@2.1.1(supports-color@7.2.0):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
encodeurl: 2.0.0
|
||||
@@ -11445,6 +11530,11 @@ snapshots:
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-to-ts@3.1.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
ts-algebra: 2.0.0
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema-typed@8.0.2: {}
|
||||
@@ -13037,7 +13127,7 @@ snapshots:
|
||||
points-on-curve: 0.2.0
|
||||
points-on-path: 0.2.1
|
||||
|
||||
router@2.2.0:
|
||||
router@2.2.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
depd: 2.0.0
|
||||
@@ -13080,7 +13170,7 @@ snapshots:
|
||||
|
||||
semver@7.8.1: {}
|
||||
|
||||
send@1.2.1:
|
||||
send@1.2.1(supports-color@7.2.0):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
encodeurl: 2.0.0
|
||||
@@ -13107,12 +13197,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
serve-static@2.2.1:
|
||||
serve-static@2.2.1(supports-color@7.2.0):
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.2.1
|
||||
send: 1.2.1(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -13267,6 +13357,11 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standardwebhooks@1.1.1:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
fast-sha256: 1.3.0
|
||||
|
||||
stat-mode@1.0.0: {}
|
||||
|
||||
state-local@1.0.7: {}
|
||||
@@ -13437,6 +13532,8 @@ snapshots:
|
||||
dependencies:
|
||||
utf8-byte-length: 1.0.5
|
||||
|
||||
ts-algebra@2.0.0: {}
|
||||
|
||||
ts-dedent@2.2.0: {}
|
||||
|
||||
ts-morph@26.0.0:
|
||||
@@ -13786,6 +13883,10 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@4.5.4):
|
||||
dependencies:
|
||||
zod: 4.5.4
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.5.4: {}
|
||||
|
||||
@@ -12,6 +12,20 @@ minimumReleaseAgeExclude:
|
||||
- zod@4.5.4
|
||||
shamefullyHoist: true
|
||||
|
||||
# Orca always launches the user's own resolved Claude CLI via
|
||||
# pathToClaudeCodeExecutable, so the SDK's bundled ~95 MB-per-platform CLI
|
||||
# binaries must never be installed. Excluding them is what makes the path
|
||||
# override mandatory rather than merely preferred.
|
||||
ignoredOptionalDependencies:
|
||||
- '@anthropic-ai/claude-agent-sdk-darwin-arm64'
|
||||
- '@anthropic-ai/claude-agent-sdk-darwin-x64'
|
||||
- '@anthropic-ai/claude-agent-sdk-linux-arm64'
|
||||
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl'
|
||||
- '@anthropic-ai/claude-agent-sdk-linux-x64'
|
||||
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl'
|
||||
- '@anthropic-ai/claude-agent-sdk-win32-arm64'
|
||||
- '@anthropic-ai/claude-agent-sdk-win32-x64'
|
||||
|
||||
supportedArchitectures:
|
||||
os:
|
||||
- current
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { ClaudeManagedAccount } from '../../shared/managed-account-types'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_VARS,
|
||||
hasClaudeAuthEnvConflict,
|
||||
shouldStripClaudeAuthEnvForAccount
|
||||
} from './environment'
|
||||
import {
|
||||
normalizeTuiAgentEnvRecord,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy'
|
||||
|
||||
const HOST_ACCOUNT = { id: 'host-a', managedAuthRuntime: 'host' } as ClaudeManagedAccount
|
||||
const WSL_ACCOUNT = { id: 'wsl-b', managedAuthRuntime: 'wsl' } as ClaudeManagedAccount
|
||||
const LEGACY_ACCOUNT = { id: 'legacy-c' } as ClaudeManagedAccount
|
||||
|
||||
function settings(
|
||||
overrides: Partial<
|
||||
Pick<
|
||||
GlobalSettings,
|
||||
| 'claudeManagedAccounts'
|
||||
| 'activeClaudeManagedAccountId'
|
||||
| 'activeClaudeManagedAccountIdsByRuntime'
|
||||
>
|
||||
>
|
||||
): Parameters<typeof claudeStructuredAuthPolicyForSettings>[0] {
|
||||
return {
|
||||
claudeManagedAccounts: [HOST_ACCOUNT, WSL_ACCOUNT, LEGACY_ACCOUNT],
|
||||
activeClaudeManagedAccountId: null,
|
||||
...overrides
|
||||
} as Parameters<typeof claudeStructuredAuthPolicyForSettings>[0]
|
||||
}
|
||||
|
||||
// The predicate now backs BOTH transports (runtime-auth-preparation.ts and the
|
||||
// structured wiring), so it needs a test of its own: forcing it to a constant used
|
||||
// to leave ~1000 tests green.
|
||||
describe('shouldStripClaudeAuthEnvForAccount', () => {
|
||||
it('does not strip when no managed account is selected', () => {
|
||||
expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], null)).toBe(false)
|
||||
expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], undefined)).toBe(false)
|
||||
expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], '')).toBe(false)
|
||||
})
|
||||
|
||||
it('strips for a host-managed account', () => {
|
||||
expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'host-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('strips for an account with no explicit runtime (the legacy host shape)', () => {
|
||||
expect(shouldStripClaudeAuthEnvForAccount([LEGACY_ACCOUNT], 'legacy-c')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not strip for a WSL-managed account, matching runtime-auth-preparation', () => {
|
||||
expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'wsl-b')).toBe(false)
|
||||
})
|
||||
|
||||
it('strips for a selected id no account list explains', () => {
|
||||
// Fail-safe: an id we cannot resolve is treated as a pinned account, never as
|
||||
// "no account", so an unreadable settings blob cannot open the strip.
|
||||
expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], 'deleted-d')).toBe(true)
|
||||
expect(shouldStripClaudeAuthEnvForAccount(undefined, 'deleted-d')).toBe(true)
|
||||
expect(shouldStripClaudeAuthEnvForAccount([], 'deleted-d')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('claudeStructuredAuthPolicyForSettings', () => {
|
||||
it('reads the host runtime selection, not the legacy flat field alone', () => {
|
||||
expect(
|
||||
claudeStructuredAuthPolicyForSettings(
|
||||
settings({
|
||||
activeClaudeManagedAccountId: 'host-a',
|
||||
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
})
|
||||
)
|
||||
).toEqual({ stripAuthEnv: true })
|
||||
})
|
||||
|
||||
it('strips when a host account is pinned by runtime selection', () => {
|
||||
expect(
|
||||
claudeStructuredAuthPolicyForSettings(
|
||||
settings({ activeClaudeManagedAccountIdsByRuntime: { host: 'host-a', wsl: {} } })
|
||||
)
|
||||
).toEqual({ stripAuthEnv: true })
|
||||
})
|
||||
|
||||
it('does not strip for system auth, so an API-key-only user keeps their sign-in', () => {
|
||||
expect(claudeStructuredAuthPolicyForSettings(settings({}))).toEqual({ stripAuthEnv: false })
|
||||
})
|
||||
|
||||
it('ignores a WSL-only selection: the structured child is always a native host process', () => {
|
||||
expect(
|
||||
claudeStructuredAuthPolicyForSettings(
|
||||
settings({
|
||||
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-b' } }
|
||||
})
|
||||
)
|
||||
).toEqual({ stripAuthEnv: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the strip vocabulary the policy governs', () => {
|
||||
it('covers every Anthropic auth variable the terminal path knows about', () => {
|
||||
// A new auth var added to the list without a matching refusal/strip path is the
|
||||
// shape of the leak this lane already shipped once.
|
||||
expect([...CLAUDE_AUTH_ENV_VARS]).toEqual([
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'AWS_BEARER_TOKEN_BEDROCK'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// The refusal has to cover exactly what the strip removes. Anything narrower lets an
|
||||
// override reach the child that applyClaudeEnvPatch would have deleted.
|
||||
describe('hasClaudeAuthEnvConflict matches the strip it guards', () => {
|
||||
it('refuses each Anthropic auth variable', () => {
|
||||
for (const key of CLAUDE_AUTH_ENV_VARS) {
|
||||
expect(hasClaudeAuthEnvConflict({ [key]: 'v' }, 'linux')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
// `ANTHROPIC_API_KEY=` in the agent env box is how a user blanks a variable, and the
|
||||
// settings pipeline preserves the empty value (agent-default-env-draft.ts assigns
|
||||
// everything after the `=`; normalizeTuiAgentEnvRecord drops empty KEYS only). An
|
||||
// empty value cannot beat the pinned account and the strip removes the name anyway,
|
||||
// so refusing it would break a terminal launch that works today for no security gain.
|
||||
it('admits an override whose value is empty, the documented way to blank a variable', () => {
|
||||
expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: '' }, 'linux')).toBe(false)
|
||||
expect(hasClaudeAuthEnvConflict({ anthropic_api_key: '' }, 'win32')).toBe(false)
|
||||
expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: '' }, 'linux')).toBe(false)
|
||||
})
|
||||
|
||||
it('still refuses the same names once they carry a value', () => {
|
||||
expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: 'sk-ant' }, 'linux')).toBe(true)
|
||||
})
|
||||
|
||||
// The end-to-end shape the regression actually took: settings text -> normalized
|
||||
// record -> launch env -> the predicate the terminal preflight gates on.
|
||||
it('admits a blanked variable all the way from the settings record', () => {
|
||||
const configured = normalizeTuiAgentEnvRecord({ claude: { ANTHROPIC_API_KEY: '' } })
|
||||
const launchEnv = resolveTuiAgentLaunchEnv('claude', configured)
|
||||
|
||||
expect(launchEnv).toEqual({ ANTHROPIC_API_KEY: '' })
|
||||
expect(hasClaudeAuthEnvConflict(launchEnv, 'linux')).toBe(false)
|
||||
})
|
||||
|
||||
it('folds case on win32, where the OS does', () => {
|
||||
expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'win32')).toBe(true)
|
||||
expect(hasClaudeAuthEnvConflict({ Anthropic_Custom_Headers: 'x-api-key: v' }, 'win32')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps env names case-sensitive off win32', () => {
|
||||
expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'linux')).toBe(false)
|
||||
})
|
||||
|
||||
it('admits non-auth Anthropic settings on both platforms', () => {
|
||||
expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'linux')).toBe(false)
|
||||
expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'win32')).toBe(false)
|
||||
expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: 'X-Trace: 1' }, 'linux')).toBe(
|
||||
false
|
||||
)
|
||||
expect(hasClaudeAuthEnvConflict(undefined, 'linux')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import { shouldStripClaudeAuthEnvForAccount } from './environment'
|
||||
import { getSelectedClaudeAccountIdForTarget } from './runtime-selection'
|
||||
|
||||
/** The structured mirror of the terminal preflight's `prepareClaudeAuth` result:
|
||||
* the one field a launch resolution needs from the managed-account state. */
|
||||
export type ClaudeStructuredAuthPolicy = {
|
||||
stripAuthEnv: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The only supported way to build a structured launch's auth policy.
|
||||
*
|
||||
* It exists as a named function rather than an inline object at the wiring site so
|
||||
* that the settings-to-policy mapping is testable on its own: the one production
|
||||
* wiring lives in a `@ts-nocheck` file, where neither the compiler nor a type test
|
||||
* can see a dropped field.
|
||||
*
|
||||
* Structured Claude always spawns a native local-host child — the launch resolver
|
||||
* refuses any record with a remote execution host or a WSL distro — so the host
|
||||
* selection, not the platform default target, owns its auth.
|
||||
*/
|
||||
export function claudeStructuredAuthPolicyForSettings(
|
||||
settings: Pick<
|
||||
GlobalSettings,
|
||||
| 'claudeManagedAccounts'
|
||||
| 'activeClaudeManagedAccountId'
|
||||
| 'activeClaudeManagedAccountIdsByRuntime'
|
||||
>
|
||||
): ClaudeStructuredAuthPolicy {
|
||||
return {
|
||||
stripAuthEnv: shouldStripClaudeAuthEnvForAccount(
|
||||
settings.claudeManagedAccounts,
|
||||
getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' })
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ClaudeManagedAccount } from '../../shared/managed-account-types'
|
||||
|
||||
export const CLAUDE_AUTH_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
@@ -13,14 +15,21 @@ export type ClaudeEnvPatch = {
|
||||
export function applyClaudeEnvPatch(
|
||||
baseEnv: Record<string, string>,
|
||||
patch: ClaudeEnvPatch,
|
||||
options?: { stripAuthEnv?: boolean }
|
||||
options?: { stripAuthEnv?: boolean; platform?: NodeJS.Platform }
|
||||
): Record<string, string> {
|
||||
if (options?.stripAuthEnv) {
|
||||
for (const key of CLAUDE_AUTH_ENV_VARS) {
|
||||
delete baseEnv[key]
|
||||
}
|
||||
if (isAuthLikeCustomHeaders(baseEnv.ANTHROPIC_CUSTOM_HEADERS)) {
|
||||
delete baseEnv.ANTHROPIC_CUSTOM_HEADERS
|
||||
const platform = options.platform ?? process.platform
|
||||
for (const key of Object.keys(baseEnv)) {
|
||||
const normalized = platform === 'win32' ? key.toUpperCase() : key
|
||||
if (
|
||||
(platform === 'win32' && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) ||
|
||||
(normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(baseEnv[key]))
|
||||
) {
|
||||
delete baseEnv[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,16 +43,94 @@ export function applyClaudeEnvPatch(
|
||||
return baseEnv
|
||||
}
|
||||
|
||||
export function hasClaudeAuthEnvConflict(env: Record<string, string> | undefined): boolean {
|
||||
if (!env) {
|
||||
/** One string for every transport, so a terminal launch and a structured launch
|
||||
* cannot drift into telling the user two different things about one refusal. */
|
||||
export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE =
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
|
||||
export const CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE =
|
||||
'A Claude account switch is in progress. Try again after it finishes.'
|
||||
|
||||
/**
|
||||
* Whether a launch on the host runtime must drop inherited Anthropic auth.
|
||||
*
|
||||
* Only a pinned host-managed account owns the credential, so only it may strip:
|
||||
* with no managed account the user's own `ANTHROPIC_*` is their sign-in, and
|
||||
* removing it signs them out of a CLI that would otherwise have worked.
|
||||
*/
|
||||
export function shouldStripClaudeAuthEnvForAccount(
|
||||
accounts: readonly ClaudeManagedAccount[] | undefined,
|
||||
activeAccountId: string | null | undefined
|
||||
): boolean {
|
||||
if (!activeAccountId) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
CLAUDE_AUTH_ENV_VARS.some((key) => Boolean(env[key])) ||
|
||||
isAuthLikeCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS)
|
||||
(accounts ?? []).find((account) => account.id === activeAccountId)?.managedAuthRuntime !== 'wsl'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a launch's explicit env carries Anthropic auth a managed account must own.
|
||||
*
|
||||
* The key comparison mirrors applyClaudeEnvPatch's strip exactly: case-insensitive on
|
||||
* win32, where the OS folds env names so `anthropic_api_key` is an effective
|
||||
* `ANTHROPIC_API_KEY`, and case-sensitive elsewhere. A refusal narrower than the strip
|
||||
* lets an override through that the strip would have removed.
|
||||
*
|
||||
* A non-empty value is what makes it a conflict. `ANTHROPIC_API_KEY=` in the agent env
|
||||
* box is how a user blanks a variable — the settings pipeline preserves that empty value
|
||||
* (normalizeTuiAgentEnvRecord drops empty KEYS only) — and an empty override can neither
|
||||
* authenticate nor beat the pinned account, while the strip removes the name regardless.
|
||||
* Refusing it would break a terminal launch that works today for no security gain.
|
||||
*/
|
||||
/**
|
||||
* The inherited Anthropic auth a non-stripping launch has to carry forward explicitly.
|
||||
*
|
||||
* applyClaudeEnvPatch always strips the inherited half of a child env, and the
|
||||
* configured half is what overrides it — so a system-auth user's own key only survives
|
||||
* if the caller puts it back deliberately. Returns the exact keys present, so a
|
||||
* win32 `anthropic_api_key` is carried under the name the OS actually has.
|
||||
*/
|
||||
export function claudeAuthEnvCarriedForward(
|
||||
inherited: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Record<string, string> {
|
||||
const carried: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(inherited)) {
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
const normalized = platform === 'win32' ? key.toUpperCase() : key
|
||||
if (
|
||||
CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized) ||
|
||||
(normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value))
|
||||
) {
|
||||
carried[key] = value
|
||||
}
|
||||
}
|
||||
return carried
|
||||
}
|
||||
|
||||
export function hasClaudeAuthEnvConflict(
|
||||
env: Record<string, string> | undefined,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): boolean {
|
||||
if (!env) {
|
||||
return false
|
||||
}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const normalized = platform === 'win32' ? key.toUpperCase() : key
|
||||
if (value && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isAuthLikeCustomHeaders(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false
|
||||
|
||||
@@ -5,6 +5,13 @@ const liveClaudePtyIds = new Set<string>()
|
||||
// survived the app restart inside the daemon.
|
||||
const seededUnconfirmedPtyIds = new Set<string>()
|
||||
let switchInProgress = false
|
||||
// Woken by endClaudeAuthSwitch so a caller past the point of no return can wait the
|
||||
// swap out instead of refusing. See whenClaudeAuthSwitchSettles.
|
||||
const switchSettledListeners = new Set<() => void>()
|
||||
|
||||
/** A managed account swap is a credential-file rewrite, not a network round trip;
|
||||
* anything past this is a wedged switch, and refusing beats waiting forever. */
|
||||
export const CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS = 15_000
|
||||
|
||||
export type ClaudeLivePtyPersistence = {
|
||||
addClaudeLivePtySessionId(sessionId: string): void
|
||||
@@ -81,6 +88,35 @@ export function markClaudePtyExited(ptyId: string): void {
|
||||
notifyDrainedOnTransition(hadLivePtys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a structured Claude child with the same gate the terminal path uses.
|
||||
*
|
||||
* The gate is what makes the managed OAuth refresh defer instead of rotating a
|
||||
* single-use refresh token out from under a running Claude (runtime-auth-sync.ts).
|
||||
* A structured session's child is as much a live Claude as a PTY's is, so it has to
|
||||
* hold the gate too — otherwise a refresh mid-turn breaks its next API call while an
|
||||
* identical terminal session is protected.
|
||||
*
|
||||
* Deliberately not persisted, unlike markClaudePtySpawned: these children are direct
|
||||
* children of this process and cannot survive a restart, so seeding them back on the
|
||||
* next launch would hold the gate closed for a process that is provably gone.
|
||||
*/
|
||||
export function markClaudeStructuredChildSpawned(childKey: string): void {
|
||||
liveClaudePtyIds.add(structuredChildGateId(childKey))
|
||||
}
|
||||
|
||||
export function markClaudeStructuredChildExited(childKey: string): void {
|
||||
const hadLivePtys = liveClaudePtyIds.size > 0
|
||||
liveClaudePtyIds.delete(structuredChildGateId(childKey))
|
||||
notifyDrainedOnTransition(hadLivePtys)
|
||||
}
|
||||
|
||||
// Namespaced so a structured child can never collide with a daemon PTY session id,
|
||||
// which confirmSeededClaudeLivePtys reconciles against the daemon's own list.
|
||||
function structuredChildGateId(childKey: string): string {
|
||||
return `claude-structured:${childKey}`
|
||||
}
|
||||
|
||||
export function hasLiveClaudePtys(): boolean {
|
||||
return liveClaudePtyIds.size > 0
|
||||
}
|
||||
@@ -93,7 +129,44 @@ export function beginClaudeAuthSwitch(): void {
|
||||
}
|
||||
|
||||
export function endClaudeAuthSwitch(): void {
|
||||
const wasInProgress = switchInProgress
|
||||
switchInProgress = false
|
||||
if (!wasInProgress) {
|
||||
return
|
||||
}
|
||||
// Each listener removes itself as it settles; Set iteration is defined over that.
|
||||
for (const listener of switchSettledListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `true` once no account switch is running, `false` if one is still running
|
||||
* at the deadline.
|
||||
*
|
||||
* Exists for callers that have already done irreversible work — a structured acquire
|
||||
* has closed the old child by the time it resolves its launch, so turning a switch
|
||||
* into a refusal there strands the user with a dead session and no replacement.
|
||||
* Waiting for the swap and then launching against it is the recoverable answer;
|
||||
* refusing is only correct when nothing has been torn down yet.
|
||||
*/
|
||||
export function whenClaudeAuthSwitchSettles(
|
||||
timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS
|
||||
): Promise<boolean> {
|
||||
if (!switchInProgress) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const settle = (settled: boolean): void => {
|
||||
switchSettledListeners.delete(listener)
|
||||
clearTimeout(timer)
|
||||
resolve(settled)
|
||||
}
|
||||
const listener = (): void => settle(true)
|
||||
switchSettledListeners.add(listener)
|
||||
const timer = setTimeout(() => settle(false), timeoutMs)
|
||||
timer.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
export function isClaudeAuthSwitchInProgress(): boolean {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { join } from 'node:path'
|
||||
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
|
||||
import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime'
|
||||
import { parseWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { shouldStripClaudeAuthEnvForAccount } from '../environment'
|
||||
import { getDefaultWslDistro, getWslHome } from '../../wsl'
|
||||
import {
|
||||
getSelectedClaudeAccountIdForTarget,
|
||||
@@ -69,7 +70,10 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh
|
||||
wslDistro: null,
|
||||
wslLinuxConfigDir: null,
|
||||
envPatch: paths.envPatch,
|
||||
stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'),
|
||||
stripAuthEnv: shouldStripClaudeAuthEnvForAccount(
|
||||
settings.claudeManagedAccounts,
|
||||
activeAccountId
|
||||
),
|
||||
managedRefreshDeferredByLivePty: Boolean(
|
||||
activeAccountId &&
|
||||
activeAccount?.managedAuthRuntime !== 'wsl' &&
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Scripted stand-in for the Claude Code CLI, driven by the SDK contract-pin
|
||||
// tests. It speaks just enough stream-json to satisfy the SDK: it answers every
|
||||
// inbound control_request with a success control_response, records everything it
|
||||
// observes to a report file, and plays back the steps listed in a scenario file.
|
||||
//
|
||||
// Env contract (set by the test):
|
||||
// ORCA_SDK_CONTRACT_SCENARIO_PATH — JSON file
|
||||
// { steps: Step[], controlResponses?: { [subtype]: <response> } } where a Step is
|
||||
// { emit: <frame> } | { awaitUserMessage: true } | { stderr: <text> } |
|
||||
// { awaitControlResponse: <request_id> } | { delayMs: <n> } | { exit: <code> }
|
||||
// ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written
|
||||
// ORCA_SDK_CONTRACT_IGNORE_SIGTERM — trap SIGTERM/SIGINT and outlive stdin close
|
||||
// ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS — record control requests but never answer
|
||||
// ORCA_SDK_CONTRACT_DESCENDANT — fork an idle grandchild and report its pid
|
||||
import { spawn } from 'node:child_process'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const scenarioPath = process.env.ORCA_SDK_CONTRACT_SCENARIO_PATH
|
||||
const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH
|
||||
|
||||
const report = {
|
||||
argv: process.argv.slice(1),
|
||||
execPath: process.execPath,
|
||||
controlRequests: [],
|
||||
controlResponses: [],
|
||||
userMessages: [],
|
||||
descendantPid: null
|
||||
}
|
||||
const writeReport = () => {
|
||||
if (reportPath) {
|
||||
writeFileSync(reportPath, JSON.stringify(report))
|
||||
}
|
||||
}
|
||||
// Written immediately so a test can prove which script the SDK executed even if
|
||||
// the session dies before the scenario completes.
|
||||
writeReport()
|
||||
|
||||
const scenario = scenarioPath ? JSON.parse(readFileSync(scenarioPath, 'utf8')) : { steps: [] }
|
||||
|
||||
if (process.env.ORCA_SDK_CONTRACT_IGNORE_SIGTERM) {
|
||||
process.on('SIGTERM', () => {})
|
||||
process.on('SIGINT', () => {})
|
||||
setInterval(() => {}, 1_000_000)
|
||||
}
|
||||
if (process.env.ORCA_SDK_CONTRACT_DESCENDANT) {
|
||||
const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000000)'], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
descendant.unref()
|
||||
report.descendantPid = descendant.pid ?? null
|
||||
writeReport()
|
||||
}
|
||||
|
||||
const emit = (frame) => process.stdout.write(`${JSON.stringify(frame)}\n`)
|
||||
|
||||
const waiters = []
|
||||
const settle = (kind, requestId) => {
|
||||
for (let i = waiters.length - 1; i >= 0; i--) {
|
||||
const waiter = waiters[i]
|
||||
if (
|
||||
waiter.kind === kind &&
|
||||
(waiter.requestId === undefined || waiter.requestId === requestId)
|
||||
) {
|
||||
waiters.splice(i, 1)
|
||||
waiter.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
const waitFor = (kind, requestId) => {
|
||||
if (kind === 'user' && report.userMessages.length > 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (
|
||||
kind === 'control_response' &&
|
||||
report.controlResponses.some((frame) => frame.response?.request_id === requestId)
|
||||
) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => waiters.push({ kind, requestId, resolve }))
|
||||
}
|
||||
|
||||
createInterface({ input: process.stdin }).on('line', (line) => {
|
||||
let frame
|
||||
try {
|
||||
frame = JSON.parse(line)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (frame.type === 'control_request') {
|
||||
report.controlRequests.push(frame)
|
||||
writeReport()
|
||||
if (process.env.ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS) {
|
||||
return
|
||||
}
|
||||
emit({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: frame.request_id,
|
||||
response: scenario.controlResponses?.[frame.request?.subtype] ?? {
|
||||
commands: [],
|
||||
models: []
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.type === 'control_response') {
|
||||
report.controlResponses.push(frame)
|
||||
writeReport()
|
||||
settle('control_response', frame.response?.request_id)
|
||||
return
|
||||
}
|
||||
if (frame.type === 'user') {
|
||||
report.userMessages.push(frame)
|
||||
writeReport()
|
||||
settle('user')
|
||||
}
|
||||
})
|
||||
|
||||
// Never outlive a wedged test: the readline subscription would otherwise hold
|
||||
// this process open forever if the SDK side stops driving the scenario.
|
||||
setTimeout(() => process.exit(3), 20_000).unref()
|
||||
|
||||
for (const step of scenario.steps) {
|
||||
if (step.emit) {
|
||||
emit(step.emit)
|
||||
} else if (step.stderr !== undefined) {
|
||||
process.stderr.write(step.stderr)
|
||||
} else if (step.awaitUserMessage) {
|
||||
await waitFor('user')
|
||||
} else if (step.awaitControlResponse !== undefined) {
|
||||
await waitFor('control_response', step.awaitControlResponse)
|
||||
} else if (step.delayMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, step.delayMs))
|
||||
} else if (step.exit !== undefined) {
|
||||
// A CLI that refuses to start: leave with its own status, stderr already written.
|
||||
writeReport()
|
||||
process.exit(step.exit)
|
||||
}
|
||||
}
|
||||
writeReport()
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,519 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
query,
|
||||
type CanUseTool,
|
||||
type Options,
|
||||
type SDKUserMessage,
|
||||
type SpawnedProcess as SdkSpawnedProcess,
|
||||
type SpawnOptions as SdkSpawnOptions
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
||||
import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests'
|
||||
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
|
||||
|
||||
// Contract pins for @anthropic-ai/claude-agent-sdk, run against the real SDK
|
||||
// driving a scripted fake CLI (never the real Claude binary). These tests exist
|
||||
// to catch a future SDK version drifting under Orca: unknown-frame pass-through,
|
||||
// spawner env fidelity, argument parity with the pre-SDK argv,
|
||||
// permission-callback semantics, and executable-path override.
|
||||
|
||||
const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs')
|
||||
const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f'
|
||||
const LEAF_UUID = 'ad0f7c9e-1b2c-4d3e-8f90-abc123def456'
|
||||
const PINNED_SDK_VERSION = '0.3.251'
|
||||
const SDK_PLATFORM_PACKAGE_BASENAMES = [
|
||||
'claude-agent-sdk-darwin-arm64',
|
||||
'claude-agent-sdk-darwin-x64',
|
||||
'claude-agent-sdk-linux-arm64',
|
||||
'claude-agent-sdk-linux-arm64-musl',
|
||||
'claude-agent-sdk-linux-x64',
|
||||
'claude-agent-sdk-linux-x64-musl',
|
||||
'claude-agent-sdk-win32-arm64',
|
||||
'claude-agent-sdk-win32-x64'
|
||||
]
|
||||
|
||||
/**
|
||||
* The exact argv the hand-rolled transport built before the SDK swap. Frozen here
|
||||
* as the parity oracle: CLAUDE_STRUCTURED_BASE_OPTIONS has to keep producing it.
|
||||
*/
|
||||
const PRE_SDK_ARGV = [
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--include-partial-messages',
|
||||
'--verbose',
|
||||
'--replay-user-messages',
|
||||
'--permission-prompt-tool',
|
||||
'stdio',
|
||||
'--setting-sources',
|
||||
'user,project,local'
|
||||
]
|
||||
|
||||
const RESULT_FRAME = {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 1,
|
||||
num_turns: 1,
|
||||
result: 'ok',
|
||||
session_id: SESSION_ID,
|
||||
total_cost_usd: 0,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
uuid: 'uuid-result-1'
|
||||
}
|
||||
|
||||
type ScenarioStep = Record<string, unknown>
|
||||
type SpawnSeen = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string | undefined
|
||||
env: Record<string, string | undefined>
|
||||
}
|
||||
type ScriptedCliReport = {
|
||||
argv: string[]
|
||||
execPath: string
|
||||
controlRequests: { request_id: string; request: { subtype: string } }[]
|
||||
controlResponses: { response: { request_id: string; response?: Record<string, unknown> } }[]
|
||||
userMessages: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
const scratchDirs: string[] = []
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
for (const dir of scratchDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function scriptScenario(
|
||||
steps: ScenarioStep[],
|
||||
controlResponses: Record<string, unknown> = {}
|
||||
): {
|
||||
scenarioPath: string
|
||||
reportPath: string
|
||||
cwd: string
|
||||
readReport: () => ScriptedCliReport
|
||||
} {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'claude-sdk-contract-'))
|
||||
scratchDirs.push(dir)
|
||||
const scenarioPath = join(dir, 'scenario.json')
|
||||
const reportPath = join(dir, 'report.json')
|
||||
writeFileSync(scenarioPath, JSON.stringify({ steps, controlResponses }))
|
||||
return {
|
||||
scenarioPath,
|
||||
reportPath,
|
||||
cwd: dir,
|
||||
readReport: () => JSON.parse(readFileSync(reportPath, 'utf8')) as ScriptedCliReport
|
||||
}
|
||||
}
|
||||
|
||||
function scenarioEnv(scenario: { scenarioPath: string; reportPath: string }) {
|
||||
return {
|
||||
PATH: process.env.PATH,
|
||||
ORCA_SDK_CONTRACT_SCENARIO_PATH: scenario.scenarioPath,
|
||||
ORCA_SDK_CONTRACT_REPORT_PATH: scenario.reportPath
|
||||
}
|
||||
}
|
||||
|
||||
function recordingSpawner(spawns: SpawnSeen[]) {
|
||||
return (opts: SdkSpawnOptions): SdkSpawnedProcess => {
|
||||
spawns.push({
|
||||
command: opts.command,
|
||||
args: [...opts.args],
|
||||
cwd: opts.cwd,
|
||||
env: { ...opts.env }
|
||||
})
|
||||
return spawnProcess({
|
||||
program: opts.command,
|
||||
args: opts.args,
|
||||
cwd: opts.cwd,
|
||||
env: opts.env as NodeJS.ProcessEnv,
|
||||
signal: opts.signal
|
||||
}) as unknown as SdkSpawnedProcess
|
||||
}
|
||||
}
|
||||
|
||||
function resolvedLaunch(launchArgs: string[]) {
|
||||
const record = {
|
||||
sessionId: 'contract-pin-session',
|
||||
provider: 'claude',
|
||||
location: {
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' },
|
||||
providerHandleChain: [],
|
||||
launchArgs
|
||||
} as unknown as AgentSessionRecord
|
||||
return createClaudeStructuredLaunchResolver({
|
||||
store: { getRecord: () => record } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async () => '/repos/workspace-1',
|
||||
resolveCommand: () => FAKE_CLI,
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: true })
|
||||
})({ identity: { sessionId: record.sessionId } as never })
|
||||
}
|
||||
|
||||
function singleUserTurn(): AsyncIterable<SDKUserMessage> {
|
||||
return (async function* () {
|
||||
yield {
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
parent_tool_use_id: null,
|
||||
session_id: SESSION_ID
|
||||
} as SDKUserMessage
|
||||
// Hold input open; the stream ends when the scripted CLI exits, and an
|
||||
// unresolved bare promise does not keep the event loop alive.
|
||||
await new Promise<void>(() => {})
|
||||
})()
|
||||
}
|
||||
|
||||
async function drainQuery(options: Options): Promise<Record<string, unknown>[]> {
|
||||
const messages: Record<string, unknown>[] = []
|
||||
for await (const message of query({ prompt: singleUserTurn(), options })) {
|
||||
messages.push(message as unknown as Record<string, unknown>)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
/** Expand `--flag=value` argv entries so both SDK spellings compare equal. */
|
||||
function normalizeArgv(args: string[]): string[] {
|
||||
return args.flatMap((arg) => {
|
||||
if (!arg.startsWith('--')) {
|
||||
return [arg]
|
||||
}
|
||||
const eq = arg.indexOf('=')
|
||||
return eq === -1 ? [arg] : [arg.slice(0, eq), arg.slice(eq + 1)]
|
||||
})
|
||||
}
|
||||
|
||||
/** Group the pre-SDK argv into flag/value pairs. */
|
||||
function flagTable(args: readonly string[]): { flag: string; value: string | null }[] {
|
||||
const table: { flag: string; value: string | null }[] = []
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const flag = args[i]!
|
||||
const next = args[i + 1]
|
||||
if (next !== undefined && !next.startsWith('-')) {
|
||||
table.push({ flag, value: next })
|
||||
i++
|
||||
} else {
|
||||
table.push({ flag, value: null })
|
||||
}
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
describe('Claude Agent SDK contract pins', () => {
|
||||
it('yields unknown types, unknown fields and unknown content blocks verbatim, and consumes keep_alive', async () => {
|
||||
const unknownTopLevel = {
|
||||
type: 'message_kind_from_the_future',
|
||||
session_id: SESSION_ID,
|
||||
uuid: 'uuid-unknown-1',
|
||||
payload: { alpha: 1, nested: { flags: ['a', 'b'] } }
|
||||
}
|
||||
const assistantWithUnknowns = {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
id: 'msg-1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-x',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello back' },
|
||||
{ type: 'content_block_from_the_future', payload: { depth: 3 } }
|
||||
],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 2 }
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: 'uuid-assistant-1',
|
||||
session_id: SESSION_ID,
|
||||
field_from_the_future: 'preserved'
|
||||
}
|
||||
const scenario = scriptScenario([
|
||||
{ awaitUserMessage: true },
|
||||
{ emit: { type: 'keep_alive' } },
|
||||
{ emit: unknownTopLevel },
|
||||
{ emit: assistantWithUnknowns },
|
||||
{ emit: RESULT_FRAME }
|
||||
])
|
||||
const spawns: SpawnSeen[] = []
|
||||
const messages = await drainQuery({
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
env: scenarioEnv(scenario),
|
||||
spawnClaudeCodeProcess: recordingSpawner(spawns)
|
||||
})
|
||||
|
||||
expect(messages.find((m) => m.uuid === 'uuid-unknown-1')).toEqual(unknownTopLevel)
|
||||
expect(messages.find((m) => m.uuid === 'uuid-assistant-1')).toEqual(assistantWithUnknowns)
|
||||
// The SDK intercepts keep_alive internally — a liveness signal must never
|
||||
// be derived from it reaching the consumer, because it does not.
|
||||
expect(messages.some((m) => m.type === 'keep_alive')).toBe(false)
|
||||
expect(messages.some((m) => m.type === 'result')).toBe(true)
|
||||
})
|
||||
|
||||
it('hands the custom spawner exactly the caller-supplied env, plus the two pinned SDK mutations', async () => {
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'ambient-key-must-not-leak')
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
const spawns: SpawnSeen[] = []
|
||||
await drainQuery({
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
env: {
|
||||
...scenarioEnv(scenario),
|
||||
CLAUDE_CONFIG_DIR: '/pinned/claude-config',
|
||||
ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-token-1',
|
||||
NODE_OPTIONS: '--max-old-space-size=64'
|
||||
},
|
||||
spawnClaudeCodeProcess: recordingSpawner(spawns)
|
||||
})
|
||||
|
||||
const env = spawns[0]!.env
|
||||
// Supplied values arrive verbatim: the config-dir pin and spawn token are
|
||||
// observable at this boundary, so Orca's auth scrubbing stays assertable.
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/pinned/claude-config')
|
||||
expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-token-1')
|
||||
// Ambient process.env is NOT merged in when env is supplied.
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
// The SDK's two documented mutations, pinned so a change is noticed.
|
||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts')
|
||||
expect('NODE_OPTIONS' in env).toBe(false)
|
||||
})
|
||||
|
||||
it('inherits process.env into the child when env is omitted — the ambient-auth sharp edge', async () => {
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
vi.stubEnv('ORCA_SDK_CONTRACT_SCENARIO_PATH', scenario.scenarioPath)
|
||||
vi.stubEnv('ORCA_SDK_CONTRACT_REPORT_PATH', scenario.reportPath)
|
||||
vi.stubEnv('ORCA_SDK_CONTRACT_AMBIENT_CANARY', 'inherited-from-process-env')
|
||||
const spawns: SpawnSeen[] = []
|
||||
await drainQuery({
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
spawnClaudeCodeProcess: recordingSpawner(spawns)
|
||||
})
|
||||
|
||||
// Omitting env reproduces the ambient-auth-leak failure mode: the child
|
||||
// sees everything in process.env. Orca must therefore always pass an
|
||||
// explicit, fully-constructed env.
|
||||
expect(spawns[0]!.env.ORCA_SDK_CONTRACT_AMBIENT_CANARY).toBe('inherited-from-process-env')
|
||||
})
|
||||
|
||||
it('emits --replay-user-messages only through extraArgs, never on its own', async () => {
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
const bareSpawns: SpawnSeen[] = []
|
||||
await drainQuery({
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
env: scenarioEnv(scenario),
|
||||
spawnClaudeCodeProcess: recordingSpawner(bareSpawns)
|
||||
})
|
||||
expect(bareSpawns[0]!.args).not.toContain('--replay-user-messages')
|
||||
|
||||
const replayScenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
const replaySpawns: SpawnSeen[] = []
|
||||
await drainQuery({
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: replayScenario.cwd,
|
||||
env: scenarioEnv(replayScenario),
|
||||
extraArgs: { 'replay-user-messages': null },
|
||||
spawnClaudeCodeProcess: recordingSpawner(replaySpawns)
|
||||
})
|
||||
const replayArgs = replaySpawns[0]!.args
|
||||
expect(replayArgs.filter((arg) => arg === '--replay-user-messages')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('produces a matching CLI flag for every pre-SDK argv entry', async () => {
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
const spawns: SpawnSeen[] = []
|
||||
// Driven by the real resolver, so the argv walk covers the durable-launchArgs
|
||||
// translation and its merge order, not a hand-written options literal.
|
||||
const launch = await resolvedLaunch(['--model', 'claude-sonnet-4-5', '--effort', 'high'])
|
||||
await drainQuery({
|
||||
...launch.options,
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
env: scenarioEnv(scenario),
|
||||
canUseTool: (async () => ({ behavior: 'deny', message: 'unused' })) as CanUseTool,
|
||||
spawnClaudeCodeProcess: recordingSpawner(spawns)
|
||||
})
|
||||
|
||||
expect(spawns).toHaveLength(1)
|
||||
const argv = normalizeArgv(spawns[0]!.args)
|
||||
// Typed-first translation must not also spell the flag through extraArgs.
|
||||
for (const flag of ['--model', '--effort']) {
|
||||
expect(
|
||||
argv.filter((arg) => arg === flag),
|
||||
`${flag} occurrences`
|
||||
).toHaveLength(1)
|
||||
}
|
||||
expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5')
|
||||
expect(argv[argv.indexOf('--effort') + 1]).toBe('high')
|
||||
// Headless print mode is the SDK's only mode; `query()` never passes `-p`,
|
||||
// and if the SDK ever started passing it this pin would notice.
|
||||
const impliedByHeadlessQuery = new Set(['-p'])
|
||||
for (const entry of flagTable(PRE_SDK_ARGV)) {
|
||||
if (impliedByHeadlessQuery.has(entry.flag)) {
|
||||
expect(argv, `${entry.flag} is implied, never spelled`).not.toContain(entry.flag)
|
||||
continue
|
||||
}
|
||||
const at = argv.indexOf(entry.flag)
|
||||
expect(at, `SDK argv is missing ${entry.flag}`).toBeGreaterThanOrEqual(0)
|
||||
if (entry.value !== null) {
|
||||
expect(argv[at + 1], `value of ${entry.flag}`).toBe(entry.value)
|
||||
}
|
||||
}
|
||||
// The launch resolver always carries one of --session-id / --resume.
|
||||
const sessionAt = argv.indexOf('--session-id')
|
||||
expect(sessionAt).toBeGreaterThanOrEqual(0)
|
||||
expect(argv[sessionAt + 1]).toBe(launch.providerSessionId)
|
||||
})
|
||||
|
||||
it('still exposes the runtime get_settings reader the auth diagnostic depends on', async () => {
|
||||
// 0.3.251 ships getSettings() but redacts it from the Query declaration. This pin
|
||||
// is the drift alarm: if a bump drops or reshapes it, the diagnostic degrades and
|
||||
// this test says so instead of the degradation shipping silently.
|
||||
const settings = { env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } }
|
||||
const scenario = scriptScenario([{ delayMs: 3_000 }], { get_settings: settings })
|
||||
const session = query({
|
||||
prompt: singleUserTurn(),
|
||||
options: {
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
env: scenarioEnv(scenario)
|
||||
}
|
||||
})
|
||||
try {
|
||||
const read = claudeQuerySettingsReader(session)
|
||||
expect(read, 'the SDK no longer exposes get_settings at runtime').not.toBeNull()
|
||||
await expect(read?.()).resolves.toEqual(settings)
|
||||
} finally {
|
||||
await session.return(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps resume identity to --resume and --resume-session-at', async () => {
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
const spawns: SpawnSeen[] = []
|
||||
await drainQuery({
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
env: scenarioEnv(scenario),
|
||||
resume: SESSION_ID,
|
||||
resumeSessionAt: LEAF_UUID,
|
||||
spawnClaudeCodeProcess: recordingSpawner(spawns)
|
||||
})
|
||||
|
||||
const argv = normalizeArgv(spawns[0]!.args)
|
||||
const resumeAt = argv.indexOf('--resume')
|
||||
expect(resumeAt).toBeGreaterThanOrEqual(0)
|
||||
expect(argv[resumeAt + 1]).toBe(SESSION_ID)
|
||||
const leafAt = argv.indexOf('--resume-session-at')
|
||||
expect(leafAt).toBeGreaterThanOrEqual(0)
|
||||
expect(argv[leafAt + 1]).toBe(LEAF_UUID)
|
||||
})
|
||||
|
||||
it('gives canUseTool the wire request_id and fires its abort signal on control_cancel_request', async () => {
|
||||
const scenario = scriptScenario([
|
||||
{ awaitUserMessage: true },
|
||||
{
|
||||
emit: {
|
||||
type: 'control_request',
|
||||
request_id: 'perm-421',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
input: { command: 'echo hi' },
|
||||
tool_use_id: 'tool-use-9'
|
||||
}
|
||||
}
|
||||
},
|
||||
{ delayMs: 120 },
|
||||
{ emit: { type: 'control_cancel_request', request_id: 'perm-421' } },
|
||||
{ awaitControlResponse: 'perm-421' },
|
||||
{ emit: RESULT_FRAME }
|
||||
])
|
||||
const seen: { toolName: string; requestId: string; toolUseID: string }[] = []
|
||||
let abortFired = false
|
||||
const canUseTool: CanUseTool = (toolName, _input, { signal, requestId, toolUseID }) => {
|
||||
seen.push({ toolName, requestId, toolUseID })
|
||||
return new Promise((resolve) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
abortFired = true
|
||||
resolve({ behavior: 'deny', message: 'cancelled by test' })
|
||||
})
|
||||
})
|
||||
}
|
||||
const spawns: SpawnSeen[] = []
|
||||
await drainQuery({
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
env: scenarioEnv(scenario),
|
||||
canUseTool,
|
||||
spawnClaudeCodeProcess: recordingSpawner(spawns)
|
||||
})
|
||||
|
||||
expect(seen).toEqual([{ toolName: 'Bash', requestId: 'perm-421', toolUseID: 'tool-use-9' }])
|
||||
expect(abortFired).toBe(true)
|
||||
// The callback's settlement is written back onto the wire against the same id.
|
||||
const settled = scenario
|
||||
.readReport()
|
||||
.controlResponses.find((frame) => frame.response.request_id === 'perm-421')
|
||||
expect(settled?.response.response?.behavior).toBe('deny')
|
||||
// Exactly one process spawn per query, control traffic included.
|
||||
expect(spawns).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('runs the executable given via pathToClaudeCodeExecutable under the default spawner', async () => {
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
|
||||
const messages = await drainQuery({
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
cwd: scenario.cwd,
|
||||
env: scenarioEnv(scenario)
|
||||
})
|
||||
|
||||
expect(messages.some((m) => m.type === 'result')).toBe(true)
|
||||
const report = scenario.readReport()
|
||||
// The SDK executed exactly the script we pointed it at — no bundled binary.
|
||||
expect(report.argv[0]).toBe(FAKE_CLI)
|
||||
expect(report.execPath).toContain('node')
|
||||
// And the streaming handshake went to it: the SDK sent its initialize
|
||||
// control request to our script.
|
||||
expect(report.controlRequests.some((frame) => frame.request.subtype === 'initialize')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('pins the SDK version the contract was verified against', () => {
|
||||
const sdkEntry = createRequire(__filename).resolve('@anthropic-ai/claude-agent-sdk')
|
||||
const manifest = JSON.parse(readFileSync(join(dirname(sdkEntry), 'package.json'), 'utf8')) as {
|
||||
version: string
|
||||
}
|
||||
expect(manifest.version).toBe(PINNED_SDK_VERSION)
|
||||
})
|
||||
|
||||
it('keeps the eight bundled CLI platform binaries out of the install', () => {
|
||||
const sdkEntry = createRequire(__filename).resolve('@anthropic-ai/claude-agent-sdk')
|
||||
// The SDK's own scoped directory is where pnpm would link its optional
|
||||
// platform packages; ignoredOptionalDependencies must keep them all absent.
|
||||
const scopeDir = dirname(dirname(sdkEntry))
|
||||
for (const basename of SDK_PLATFORM_PACKAGE_BASENAMES) {
|
||||
expect(
|
||||
existsSync(join(scopeDir, basename, 'package.json')),
|
||||
`${basename} must not be installed`
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
PermissionMode,
|
||||
Query,
|
||||
SDKControlInterruptResponse
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
export class ClaudeControlRequestError extends Error {
|
||||
constructor(
|
||||
readonly subtype: string,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ClaudeControlRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
export const CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
/** The SDK closes a query out from under an in-flight control request with this exact message. */
|
||||
const QUERY_CLOSED_MESSAGE = 'Query closed before response received'
|
||||
|
||||
/** 0.3.251 ships getSettings() but redacts it from the Query declaration; the typeof guard below is its degradation path. */
|
||||
type ClaudeQuerySettingsReader = { getSettings?: () => Promise<unknown> }
|
||||
|
||||
export function claudeQuerySettingsReader(query: Query): (() => Promise<unknown>) | null {
|
||||
const reader = (query as unknown as ClaudeQuerySettingsReader).getSettings
|
||||
return typeof reader === 'function' ? reader.bind(query) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* cancel_async_message is a runtime Query method the shipped 0.3.251 declaration omits;
|
||||
* it withdraws a single still-queued async user message by uuid so an interrupted turn
|
||||
* cannot spawn a later unexpected turn. The typeof guard is its degradation path.
|
||||
*/
|
||||
type ClaudeQueryAsyncCanceller = { cancelAsyncMessage?: (uuid: string) => Promise<unknown> }
|
||||
|
||||
export function claudeQueryAsyncCanceller(
|
||||
query: Query
|
||||
): ((uuid: string) => Promise<unknown>) | null {
|
||||
const cancel = (query as unknown as ClaudeQueryAsyncCanceller).cancelAsyncMessage
|
||||
return typeof cancel === 'function' ? cancel.bind(query) : null
|
||||
}
|
||||
|
||||
export type ClaudeControlOptions = { timeoutMs?: number }
|
||||
|
||||
/**
|
||||
* Run one native Query control method under Orca's deadline and error classification.
|
||||
*
|
||||
* The SDK owns correlation but applies no deadline, so the timeout stays here — and its
|
||||
* message is load-bearing: the init proof matches on `claude initialize request timed out`.
|
||||
* A closed query is a transport failure, not the CLI rejecting the request, so only the
|
||||
* latter is re-thrown as a `ClaudeControlRequestError` a caller may surface as a rejection.
|
||||
*/
|
||||
export function runClaudeControl<T>(
|
||||
subtype: string,
|
||||
run: () => Promise<T>,
|
||||
timeoutMs: number = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`claude ${subtype} request timed out`)), timeoutMs)
|
||||
timer.unref?.()
|
||||
})
|
||||
return Promise.race([
|
||||
Promise.resolve()
|
||||
.then(run)
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (error instanceof ClaudeControlRequestError || message === QUERY_CLOSED_MESSAGE) {
|
||||
throw error
|
||||
}
|
||||
throw new ClaudeControlRequestError(subtype, message)
|
||||
}),
|
||||
deadline
|
||||
]).finally(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** The native control surface Orca drives, one method per Query control request. */
|
||||
export type ClaudeControlSurface = {
|
||||
interrupt: (
|
||||
options?: ClaudeControlOptions & { cancelQueued?: boolean }
|
||||
) => Promise<SDKControlInterruptResponse | undefined>
|
||||
cancelAsyncMessage: (uuid: string, options?: ClaudeControlOptions) => Promise<void>
|
||||
setModel: (model: string | undefined, options?: ClaudeControlOptions) => Promise<void>
|
||||
setPermissionMode: (mode: PermissionMode, options?: ClaudeControlOptions) => Promise<void>
|
||||
applyFlagSettings: (
|
||||
settings: Parameters<Query['applyFlagSettings']>[0],
|
||||
options?: ClaudeControlOptions
|
||||
) => Promise<void>
|
||||
supportedModels: (options?: ClaudeControlOptions) => Promise<unknown[]>
|
||||
initializationResult: (options?: ClaudeControlOptions) => Promise<unknown>
|
||||
getSettings: (options?: ClaudeControlOptions) => Promise<unknown>
|
||||
}
|
||||
|
||||
type InterruptingQuery = {
|
||||
interrupt: (options?: {
|
||||
cancelQueued?: boolean
|
||||
}) => Promise<SDKControlInterruptResponse | undefined>
|
||||
}
|
||||
|
||||
export function createClaudeControlSurface(query: Query): ClaudeControlSurface {
|
||||
return {
|
||||
interrupt: (options) =>
|
||||
runClaudeControl(
|
||||
'interrupt',
|
||||
() =>
|
||||
(query as unknown as InterruptingQuery).interrupt(
|
||||
options?.cancelQueued ? { cancelQueued: true } : undefined
|
||||
),
|
||||
options?.timeoutMs
|
||||
),
|
||||
cancelAsyncMessage: (uuid, options) => {
|
||||
const cancel = claudeQueryAsyncCanceller(query)
|
||||
return cancel
|
||||
? runClaudeControl('cancel_async_message', () => cancel(uuid), options?.timeoutMs).then(
|
||||
() => {}
|
||||
)
|
||||
: Promise.resolve()
|
||||
},
|
||||
setModel: (model, options) =>
|
||||
runClaudeControl('set_model', () => query.setModel(model), options?.timeoutMs).then(() => {}),
|
||||
setPermissionMode: (mode, options) =>
|
||||
runClaudeControl(
|
||||
'set_permission_mode',
|
||||
() => query.setPermissionMode(mode),
|
||||
options?.timeoutMs
|
||||
).then(() => {}),
|
||||
applyFlagSettings: (settings, options) =>
|
||||
runClaudeControl(
|
||||
'apply_flag_settings',
|
||||
() => query.applyFlagSettings(settings),
|
||||
options?.timeoutMs
|
||||
).then(() => {}),
|
||||
supportedModels: (options) =>
|
||||
runClaudeControl('list_models', () => query.supportedModels(), options?.timeoutMs),
|
||||
initializationResult: (options) =>
|
||||
runClaudeControl('initialize', () => query.initializationResult(), options?.timeoutMs),
|
||||
getSettings: (options) => {
|
||||
const read = claudeQuerySettingsReader(query)
|
||||
return read
|
||||
? runClaudeControl('get_settings', read, options?.timeoutMs)
|
||||
: Promise.reject(
|
||||
new ClaudeControlRequestError(
|
||||
'get_settings',
|
||||
'this SDK exposes no get_settings request'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DescendantSnapshot } from '../pty-descendant-termination'
|
||||
import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification'
|
||||
import { collectDescendantRows } from '../pty-descendant-termination'
|
||||
import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof'
|
||||
import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot'
|
||||
|
||||
function posixSnapshot(capturedAtMs: number): DescendantSnapshot {
|
||||
return {
|
||||
root: { pid: 100, startedAt: 'Mon Jan 1 00:00:00 2026' },
|
||||
rootPgid: 100,
|
||||
descendants: [{ pid: 200, ppid: 100, pgid: 100, startedAt: 'Mon Jan 1 00:00:01 2026' }],
|
||||
capturedAtMs
|
||||
}
|
||||
}
|
||||
|
||||
function windowsSnapshot(): WindowsDescendantSnapshot {
|
||||
return {
|
||||
root: { pid: 100, creationTimeMs: 5 },
|
||||
descendants: [{ pid: 200, creationTimeMs: 7 }],
|
||||
unidentifiedCount: 0,
|
||||
capturedAtMs: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude child root identity', () => {
|
||||
it('keeps a retained row boundary when a refresh observes no new descendants', () => {
|
||||
const previous = posixSnapshot(1_700_000_000_900)
|
||||
const next = posixSnapshot(1_700_000_002_100)
|
||||
|
||||
expect(
|
||||
mergeClaudeCapturedTrees(
|
||||
{ platform: 'posix', tree: previous },
|
||||
{ platform: 'posix', tree: next }
|
||||
)
|
||||
).toEqual({
|
||||
platform: 'posix',
|
||||
tree: { ...next, capturedAtMsByPid: { '200': previous.capturedAtMs } }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the descendant verdict when a POSIX root probe is unavailable', async () => {
|
||||
const child = { pid: 100, kill: vi.fn(() => true) }
|
||||
const terminateDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants: vi.fn(async () => posixSnapshot(1)),
|
||||
terminateDescendants,
|
||||
verifyRootIdentity: vi.fn(async () => false)
|
||||
})
|
||||
|
||||
// POSIX runs no bare-pid root operation, so a declined probe withholds
|
||||
// nothing: the handle kill still lands and the verification still speaks.
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
expect(terminateDescendants).toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('rejects mixed old and recycled root rows instead of making the tree killable', async () => {
|
||||
const child = { pid: 100, kill: vi.fn(() => true) }
|
||||
const terminateDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants: vi.fn(async () =>
|
||||
collectDescendantRows(
|
||||
100,
|
||||
[
|
||||
{ pid: 100, ppid: 1, pgid: 100, startedAt: 'Mon Jan 1 00:00:00 2026' },
|
||||
{ pid: 100, ppid: 1, pgid: 101, startedAt: 'Mon Jan 1 00:00:01 2026' },
|
||||
{ pid: 200, ppid: 100, pgid: 200, startedAt: 'Mon Jan 1 00:00:00 2026' }
|
||||
],
|
||||
1
|
||||
)
|
||||
),
|
||||
terminateDescendants,
|
||||
verifyRootIdentity: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
// No admissible snapshot means no row may be signalled from its number, but
|
||||
// the root still leaves through the handle Node owns.
|
||||
expect(terminateDescendants).not.toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('fails closed when Windows root identity revalidation is unavailable', async () => {
|
||||
const child = { pid: 100, kill: vi.fn(() => true) }
|
||||
const terminateWindowsTree = vi.fn(async () => {})
|
||||
const terminateWindowsDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
captureWindowsDescendants: vi.fn(async () => windowsSnapshot()),
|
||||
terminateWindowsTree,
|
||||
terminateWindowsDescendants,
|
||||
verifyRootIdentity: vi.fn(async () => false)
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
// taskkill /T /F addresses a bare pid and stays gated; the handle does not.
|
||||
expect(terminateWindowsTree).not.toHaveBeenCalled()
|
||||
expect(terminateWindowsDescendants).not.toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,934 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process'
|
||||
import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification'
|
||||
import type { DescendantSnapshot } from '../pty-descendant-termination'
|
||||
import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification'
|
||||
import {
|
||||
createClaudeChildTreeReaper as createClaudeChildTreeReaperImpl,
|
||||
proveClaudeChildExit,
|
||||
type ClaudeChildTreeReaper
|
||||
} from './claude-agent-sdk-exit-proof'
|
||||
|
||||
// The descendant models an MCP server: it either cooperates or, when it traps
|
||||
// SIGTERM, only a forced, verified sweep can reach it. The root either traps
|
||||
// SIGTERM too, or leaves promptly on stdin end the way a healthy CLI does —
|
||||
// which is the path that used to skip descendant proof entirely.
|
||||
function childWithDescendantScript(input: {
|
||||
rootTrapsSigterm: boolean
|
||||
descendantTrapsSigterm: boolean
|
||||
}): string {
|
||||
const descendantScript = `${input.descendantTrapsSigterm ? 'process.on("SIGTERM", () => {}); ' : ''}setInterval(() => {}, 1000000)`
|
||||
const rootBehaviour = input.rootTrapsSigterm
|
||||
? `process.on('SIGTERM', () => {})
|
||||
process.on('SIGINT', () => {})
|
||||
setInterval(() => {}, 1000000)`
|
||||
: `process.stdin.on('end', () => process.exit(0))
|
||||
process.stdin.resume()`
|
||||
return `
|
||||
const descendant = require('node:child_process').spawn(
|
||||
process.execPath,
|
||||
['-e', ${JSON.stringify(descendantScript)}],
|
||||
{ stdio: 'ignore' }
|
||||
)
|
||||
descendant.unref()
|
||||
process.stdout.write(JSON.stringify({ descendantPid: descendant.pid }) + '\\n')
|
||||
${rootBehaviour}
|
||||
`
|
||||
}
|
||||
|
||||
const COOPERATIVE_CHILD = `
|
||||
process.stdin.on('end', () => process.exit(0))
|
||||
process.stdin.resume()
|
||||
process.stdout.write('ready\\n')
|
||||
`
|
||||
|
||||
/**
|
||||
* Sampled synchronously so it reads the exact moment the close boundary is
|
||||
* crossed. A zombie has exited (its parent just has not reaped it yet), so a
|
||||
* kill(pid, 0) probe would misreport it as running.
|
||||
*/
|
||||
function descendantState(pid: number): 'running' | 'exited' {
|
||||
let state: string
|
||||
try {
|
||||
state = execFileSync('ps', ['-o', 'state=', '-p', String(pid)], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LANG: 'C', LC_ALL: 'C' }
|
||||
}).trim()
|
||||
} catch (error) {
|
||||
// ps exits 1 when no process matches; anything else is a failed probe, not an answer.
|
||||
if ((error as { status?: number }).status !== 1) {
|
||||
throw error
|
||||
}
|
||||
return 'exited'
|
||||
}
|
||||
return state.startsWith('Z') ? 'exited' : 'running'
|
||||
}
|
||||
|
||||
/**
|
||||
* ps lstart is second-resolution, so the identity-safe sweep only SIGKILLs a row
|
||||
* born strictly before the second the snapshot was captured in. The snapshot is
|
||||
* armed the moment close begins, so a descendant born in that same second can
|
||||
* only be asked, never forced — the same bound an MCP server spawned within a
|
||||
* second of the user closing the chat would hit.
|
||||
*/
|
||||
function ageDescendantPastTheCaptureSecond(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 1_000 - (Date.now() % 1_000) + 20))
|
||||
}
|
||||
|
||||
/**
|
||||
* The close ladder as production drives it: `closeProcessRegistry` retries an
|
||||
* unproven close, and each retry re-verifies the retained snapshot. A loaded
|
||||
* host can spend one attempt's whole window inside `ps`, and reporting false
|
||||
* there is the honest verdict — the requirement is that TRUE never outruns the
|
||||
* observation, which the caller asserts at whichever boundary returns it.
|
||||
*/
|
||||
async function proveExitWithRetries(
|
||||
input: Parameters<typeof proveClaudeChildExit>[0],
|
||||
attempts = 3
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 1; attempt < attempts; attempt += 1) {
|
||||
if (await proveClaudeChildExit(input)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return proveClaudeChildExit(input)
|
||||
}
|
||||
|
||||
function spawnScript(script: string): ReturnType<typeof spawnProcess> {
|
||||
return spawnProcess({
|
||||
program: process.execPath,
|
||||
args: ['-e', script],
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
}
|
||||
|
||||
function firstStdoutLine(child: ReturnType<typeof spawnProcess>): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
child.stdout.setEncoding('utf8').once('data', (chunk: string) => resolve(chunk.trim()))
|
||||
})
|
||||
}
|
||||
|
||||
function observeExit(child: EventEmitter): { exitPromise: Promise<void>; exited: () => boolean } {
|
||||
let exited = false
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
child.once('exit', () => {
|
||||
exited = true
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
return { exitPromise, exited: () => exited }
|
||||
}
|
||||
|
||||
/** `null` models a spawn that failed before a pid existed. */
|
||||
function mockChild(
|
||||
pid: number | null = 424242
|
||||
): EventEmitter &
|
||||
Pick<SpawnedProcess, 'pid' | 'kill' | 'stdin'> & { kill: ReturnType<typeof vi.fn> } {
|
||||
const child = new EventEmitter()
|
||||
return Object.assign(child, {
|
||||
pid: pid ?? undefined,
|
||||
stdin: new PassThrough(),
|
||||
kill: vi.fn(() => true)
|
||||
}) as never
|
||||
}
|
||||
|
||||
/** A tree whose verdict is scripted per reap, recording when it was armed. */
|
||||
function mockTree(verdicts: DescendantTreeVerdict[]): ClaudeChildTreeReaper & {
|
||||
capture: ReturnType<typeof vi.fn>
|
||||
reap: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
let treeVerdict: DescendantTreeVerdict = 'unverifiable'
|
||||
return {
|
||||
capture: vi.fn(async () => {}),
|
||||
reap: vi.fn(async () => {
|
||||
treeVerdict = verdicts.shift() ?? treeVerdict
|
||||
return treeVerdict
|
||||
}),
|
||||
get treeVerdict() {
|
||||
return treeVerdict
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function windowsSnapshotOf(descendantPid: number): WindowsDescendantSnapshot {
|
||||
return {
|
||||
root: { pid: 424242, creationTimeMs: 1_700_000_000_001 },
|
||||
descendants: [{ pid: descendantPid, creationTimeMs: 1_700_000_000_000 }],
|
||||
unidentifiedCount: 0,
|
||||
capturedAtMs: 1
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotOf(descendantPid: number): DescendantSnapshot {
|
||||
return {
|
||||
root: { pid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' },
|
||||
rootPgid: 1,
|
||||
descendants: [
|
||||
{ pid: descendantPid, ppid: 424242, pgid: 1, startedAt: 'Mon Jan 1 00:00:00 2026' }
|
||||
],
|
||||
capturedAtMs: 1
|
||||
}
|
||||
}
|
||||
|
||||
// Unit tests use synthetic process ids; production always supplies the fresh
|
||||
// identity probe, so the harness explicitly models a matching probe.
|
||||
function createClaudeChildTreeReaper(
|
||||
child: Parameters<typeof createClaudeChildTreeReaperImpl>[0],
|
||||
deps: Parameters<typeof createClaudeChildTreeReaperImpl>[1] = {}
|
||||
): ReturnType<typeof createClaudeChildTreeReaperImpl> {
|
||||
return createClaudeChildTreeReaperImpl(child, {
|
||||
verifyRootIdentity: async () => true,
|
||||
...deps
|
||||
})
|
||||
}
|
||||
|
||||
describe('claude child exit proof', () => {
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'reports a proven exit only once a SIGTERM-resistant descendant is gone at the close boundary',
|
||||
async () => {
|
||||
const child = spawnScript(
|
||||
childWithDescendantScript({ rootTrapsSigterm: true, descendantTrapsSigterm: true })
|
||||
)
|
||||
const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as {
|
||||
descendantPid: number
|
||||
}
|
||||
expect(descendantState(descendantPid)).toBe('running')
|
||||
await ageDescendantPastTheCaptureSecond()
|
||||
|
||||
try {
|
||||
const proven = await proveExitWithRetries({ child, ...observeExit(child) })
|
||||
// Evaluated AT the boundary, not by polling until a deferred sweep timer
|
||||
// wins: true releases the lease, so a descendant still running here is
|
||||
// exactly the orphan the proof exists to prevent. False would be the
|
||||
// honest verdict for a tree that outlived the bounded ladder.
|
||||
expect({ proven, descendant: descendantState(descendantPid) }).toEqual({
|
||||
proven: true,
|
||||
descendant: 'exited'
|
||||
})
|
||||
} finally {
|
||||
// Failure-safe only: the assertion above owns the requirement, this just
|
||||
// stops a failing run from leaking a process.
|
||||
try {
|
||||
process.kill(descendantPid, 'SIGKILL')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
},
|
||||
20_000
|
||||
)
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'proves a promptly exiting root only once its stubborn descendant is gone too',
|
||||
async () => {
|
||||
// The ordinary healthy close: the root leaves on stdin end within the graceful
|
||||
// window. Its descendant must still be proven gone, not assumed gone with it.
|
||||
const child = spawnScript(
|
||||
childWithDescendantScript({ rootTrapsSigterm: false, descendantTrapsSigterm: true })
|
||||
)
|
||||
const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as {
|
||||
descendantPid: number
|
||||
}
|
||||
expect(descendantState(descendantPid)).toBe('running')
|
||||
await ageDescendantPastTheCaptureSecond()
|
||||
|
||||
try {
|
||||
const proven = await proveExitWithRetries({ child, ...observeExit(child) })
|
||||
expect({ proven, descendant: descendantState(descendantPid) }).toEqual({
|
||||
proven: true,
|
||||
descendant: 'exited'
|
||||
})
|
||||
} finally {
|
||||
try {
|
||||
process.kill(descendantPid, 'SIGKILL')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
},
|
||||
20_000
|
||||
)
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'still proves a stubborn child whose descendant honours SIGTERM',
|
||||
async () => {
|
||||
const child = spawnScript(
|
||||
childWithDescendantScript({ rootTrapsSigterm: true, descendantTrapsSigterm: false })
|
||||
)
|
||||
const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as {
|
||||
descendantPid: number
|
||||
}
|
||||
try {
|
||||
const proven = await proveExitWithRetries({ child, ...observeExit(child) })
|
||||
expect({ proven, descendant: descendantState(descendantPid) }).toEqual({
|
||||
proven: true,
|
||||
descendant: 'exited'
|
||||
})
|
||||
} finally {
|
||||
try {
|
||||
process.kill(descendantPid, 'SIGKILL')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
},
|
||||
20_000
|
||||
)
|
||||
|
||||
it('arms the snapshot before stdin closes and verifies it after a clean exit', async () => {
|
||||
const child = spawnScript(COOPERATIVE_CHILD)
|
||||
expect(await firstStdoutLine(child)).toBe('ready')
|
||||
const exit = observeExit(child)
|
||||
const tree = mockTree(['exited'])
|
||||
let exitedWhenArmed: boolean | null = null
|
||||
tree.capture.mockImplementation(async () => {
|
||||
exitedWhenArmed = exit.exited()
|
||||
})
|
||||
|
||||
await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(true)
|
||||
// The snapshot is the only proof that survives the root: taken while it lived,
|
||||
// verified once it left. A reap before the exit would have been the forced ladder.
|
||||
expect(exitedWhenArmed).toBe(false)
|
||||
expect(tree.reap).toHaveBeenCalledTimes(1)
|
||||
expect(exit.exited()).toBe(true)
|
||||
}, 20_000)
|
||||
|
||||
it('proves a clean close of a childless root with one snapshot and no signal', async () => {
|
||||
const child = spawnScript(COOPERATIVE_CHILD)
|
||||
expect(await firstStdoutLine(child)).toBe('ready')
|
||||
|
||||
await expect(proveClaudeChildExit({ child, ...observeExit(child) })).resolves.toBe(true)
|
||||
}, 20_000)
|
||||
|
||||
it('reports an unprovable exit as false rather than assuming the child died', async () => {
|
||||
const child = mockChild()
|
||||
const tree = mockTree(['exited'])
|
||||
|
||||
await expect(
|
||||
proveClaudeChildExit({
|
||||
child,
|
||||
exitPromise: new Promise<void>(() => {}),
|
||||
exited: () => false,
|
||||
tree
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(tree.reap).toHaveBeenCalledTimes(1)
|
||||
}, 20_000)
|
||||
|
||||
it('reports false when the root exit was observed but a descendant was seen alive', async () => {
|
||||
const child = mockChild()
|
||||
const exit = observeExit(child)
|
||||
const tree = mockTree(['live'])
|
||||
tree.reap.mockImplementation(async () => {
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
return 'live'
|
||||
})
|
||||
|
||||
await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(false)
|
||||
expect(exit.exited()).toBe(true)
|
||||
// One verification per attempt: the retried close re-verifies, this one does not.
|
||||
expect(tree.reap).toHaveBeenCalledTimes(1)
|
||||
}, 20_000)
|
||||
|
||||
it('re-verifies an unproven tree on a retried close instead of trusting the dead root', async () => {
|
||||
const child = mockChild()
|
||||
const tree = mockTree(['exited'])
|
||||
|
||||
await expect(
|
||||
proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree })
|
||||
).resolves.toBe(true)
|
||||
expect(tree.reap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stays unproven for a root that left before any snapshot could be armed', async () => {
|
||||
const child = mockChild()
|
||||
const captureDescendants = vi.fn(async () => snapshotOf(4243))
|
||||
const terminateDescendants = vi.fn()
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'darwin',
|
||||
exited: () => true,
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await expect(
|
||||
proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree })
|
||||
).resolves.toBe(false)
|
||||
// A dead root's descendants have reparented: walking its pid now could only
|
||||
// sweep a stranger, so no walk is attempted and nothing is proven.
|
||||
expect(captureDescendants).not.toHaveBeenCalled()
|
||||
expect(terminateDescendants).not.toHaveBeenCalled()
|
||||
expect(tree.treeVerdict).toBe('unverifiable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('claude child tree reaper', () => {
|
||||
it('kills the root while verification runs and never stops it first', async () => {
|
||||
const child = mockChild()
|
||||
const release = Promise.withResolvers<DescendantTreeVerdict>()
|
||||
const terminateDescendants = vi.fn(() => release.promise)
|
||||
const captureDescendants = vi.fn(async () => snapshotOf(4243))
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'darwin',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
const first = tree.reap()
|
||||
const second = tree.reap()
|
||||
await vi.waitFor(() => expect(terminateDescendants).toHaveBeenCalledTimes(1))
|
||||
// A stopped root cannot verify: its killed children stay zombie rows in ps.
|
||||
expect(child.kill.mock.calls).toEqual([['SIGKILL']])
|
||||
expect(tree.treeVerdict).toBe('unverifiable')
|
||||
|
||||
release.resolve('exited')
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(['exited', 'exited'])
|
||||
expect(captureDescendants).toHaveBeenCalledTimes(1)
|
||||
expect(tree.treeVerdict).toBe('exited')
|
||||
})
|
||||
|
||||
it('re-verifies the retained snapshot on a later reap rather than re-walking a dead root', async () => {
|
||||
const child = mockChild()
|
||||
const captureDescendants = vi.fn(async () => snapshotOf(4243))
|
||||
const terminateDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce('live')
|
||||
.mockResolvedValueOnce('exited')
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('live')
|
||||
expect(tree.treeVerdict).toBe('live')
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
expect(captureDescendants).toHaveBeenCalledTimes(1)
|
||||
expect(terminateDescendants).toHaveBeenNthCalledWith(2, snapshotOf(4243))
|
||||
expect(tree.treeVerdict).toBe('exited')
|
||||
})
|
||||
|
||||
it('keeps an observed exit when a later re-read cannot see the table', async () => {
|
||||
const child = mockChild()
|
||||
const terminateDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce('exited')
|
||||
.mockResolvedValueOnce('unverifiable')
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants: vi.fn(async () => snapshotOf(4243)),
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
expect(tree.treeVerdict).toBe('exited')
|
||||
})
|
||||
|
||||
it('keeps an observed live descendant when a later re-read cannot see the table', async () => {
|
||||
const child = mockChild()
|
||||
// Reap #1 completed and saw a descendant alive at its deadline; the root then
|
||||
// left on its own and the re-verification on a loaded host could not read the
|
||||
// table. "Could not look" must not erase "was seen alive": the lease release
|
||||
// gate is exactly the pair this distinguishes.
|
||||
const terminateDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce('live')
|
||||
.mockResolvedValueOnce('unverifiable')
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants: vi.fn(async () => snapshotOf(4243)),
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('live')
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
expect(tree.treeVerdict).toBe('live')
|
||||
})
|
||||
|
||||
it('treats an unreadable process table as unproven and re-walks the live root', async () => {
|
||||
const child = mockChild()
|
||||
// A loaded host can miss the table's deadline; while the root still lives
|
||||
// that is a retryable read, not evidence that it has no descendants.
|
||||
const captureDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(snapshotOf(4243))
|
||||
const terminateDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
expect(terminateDescendants).not.toHaveBeenCalled()
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
expect(captureDescendants).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not latch a missing root while it is still live', async () => {
|
||||
const child = mockChild()
|
||||
const captureDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ rootPgid: null, descendants: [], capturedAtMs: 1 })
|
||||
.mockResolvedValueOnce(snapshotOf(4243))
|
||||
const terminateDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
expect(captureDescendants).toHaveBeenCalledTimes(2)
|
||||
expect(terminateDescendants).toHaveBeenCalledWith(snapshotOf(4243))
|
||||
})
|
||||
|
||||
it('refreshes the live snapshot at close time so late descendants are included', async () => {
|
||||
const child = mockChild()
|
||||
const first = snapshotOf(4243)
|
||||
const second = {
|
||||
...first,
|
||||
descendants: [...first.descendants, { ...first.descendants[0], pid: 4244 }]
|
||||
}
|
||||
const captureDescendants = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second)
|
||||
const terminateDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await tree.refresh?.()
|
||||
await tree.reap()
|
||||
|
||||
expect(captureDescendants).toHaveBeenCalledTimes(2)
|
||||
expect(terminateDescendants).toHaveBeenCalledWith(second)
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('keeps the original capture boundary for retained POSIX rows', async () => {
|
||||
const child = mockChild()
|
||||
const first = {
|
||||
...snapshotOf(4243),
|
||||
capturedAtMs: 1_700_000_000_900
|
||||
}
|
||||
const refreshed = {
|
||||
...first,
|
||||
capturedAtMs: 1_700_000_002_100,
|
||||
descendants: [
|
||||
...first.descendants,
|
||||
{
|
||||
pid: 4244,
|
||||
ppid: 424242,
|
||||
pgid: 1,
|
||||
startedAt: 'Tue Jan 2 00:00:00 2026'
|
||||
}
|
||||
]
|
||||
}
|
||||
const captureDescendants = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(refreshed)
|
||||
const terminateDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await tree.refresh?.()
|
||||
await tree.reap()
|
||||
|
||||
expect(terminateDescendants).toHaveBeenCalledWith({
|
||||
...refreshed,
|
||||
// The retained 4243 row was first observed in the earlier displayed
|
||||
// second. Its per-row boundary must not advance with the refresh.
|
||||
capturedAtMsByPid: {
|
||||
'4243': first.capturedAtMs,
|
||||
'4244': refreshed.capturedAtMs
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when a POSIX refresh reuses a PID with a new identity', async () => {
|
||||
const child = mockChild()
|
||||
const first = snapshotOf(4243)
|
||||
const replacement = {
|
||||
...first,
|
||||
descendants: [
|
||||
{
|
||||
...first.descendants[0],
|
||||
pgid: 9,
|
||||
startedAt: 'Tue Jan 2 00:00:00 2026'
|
||||
}
|
||||
]
|
||||
}
|
||||
const captureDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce(replacement)
|
||||
const terminateDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await tree.refresh?.()
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
// The descendant evidence is discarded; the root's identity never was in doubt.
|
||||
expect(terminateDescendants).not.toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('fails closed when a Windows refresh reuses a PID with a new creation time', async () => {
|
||||
const child = mockChild()
|
||||
const first = windowsSnapshotOf(4243)
|
||||
const replacement = {
|
||||
...first,
|
||||
descendants: [{ pid: 4243, creationTimeMs: first.descendants[0].creationTimeMs + 1 }]
|
||||
}
|
||||
const captureWindowsDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce(replacement)
|
||||
const terminateWindowsTree = vi.fn(async () => {})
|
||||
const terminateWindowsDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
captureWindowsDescendants,
|
||||
terminateWindowsTree,
|
||||
terminateWindowsDescendants
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await tree.refresh?.()
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
expect(terminateWindowsTree).not.toHaveBeenCalled()
|
||||
expect(terminateWindowsDescendants).not.toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('queues a fresh boundary behind an output-triggered capture already in flight', async () => {
|
||||
const child = mockChild()
|
||||
const firstDone = Promise.withResolvers<void>()
|
||||
const first = snapshotOf(4243)
|
||||
const second = {
|
||||
...first,
|
||||
descendants: [...first.descendants, { ...first.descendants[0], pid: 4244 }]
|
||||
}
|
||||
const captureDescendants = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
await firstDone.promise
|
||||
return first
|
||||
})
|
||||
.mockResolvedValueOnce(second)
|
||||
const terminateDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
const outputCapture = tree.refresh!()
|
||||
await vi.waitFor(() => expect(captureDescendants).toHaveBeenCalledTimes(1))
|
||||
const closeCapture = tree.refresh!()
|
||||
await Promise.resolve()
|
||||
expect(captureDescendants).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstDone.resolve()
|
||||
await closeCapture
|
||||
await tree.reap()
|
||||
|
||||
expect(captureDescendants).toHaveBeenCalledTimes(2)
|
||||
expect(terminateDescendants).toHaveBeenCalledWith(second)
|
||||
await outputCapture
|
||||
})
|
||||
|
||||
it('retains a replacement descendant when the prior identity exited', async () => {
|
||||
const child = mockChild()
|
||||
const first = snapshotOf(4243)
|
||||
const replacement = snapshotOf(4244)
|
||||
const captureDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce(replacement)
|
||||
const terminateDescendants = vi.fn(async (snapshot: DescendantSnapshot) =>
|
||||
snapshot.descendants.some((row) => row.pid === 4244) ? ('live' as const) : ('exited' as const)
|
||||
)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await tree.refresh?.()
|
||||
await expect(tree.reap()).resolves.toBe('live')
|
||||
|
||||
expect(terminateDescendants).toHaveBeenCalledWith({
|
||||
...replacement,
|
||||
descendants: [...first.descendants, ...replacement.descendants]
|
||||
})
|
||||
})
|
||||
|
||||
it('retains a Windows replacement descendant while preserving unidentified rows', async () => {
|
||||
const child = mockChild()
|
||||
const first = windowsSnapshotOf(4243)
|
||||
const replacement = {
|
||||
...windowsSnapshotOf(4244),
|
||||
unidentifiedCount: 0
|
||||
}
|
||||
const captureWindowsDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ...first, unidentifiedCount: 1 })
|
||||
.mockResolvedValueOnce(replacement)
|
||||
const terminateWindowsTree = vi.fn(async () => {})
|
||||
const terminateWindowsDescendants = vi.fn(async (snapshot: WindowsDescendantSnapshot) =>
|
||||
snapshot.descendants.some((row) => row.pid === 4244) ? ('live' as const) : ('exited' as const)
|
||||
)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
captureWindowsDescendants,
|
||||
terminateWindowsTree,
|
||||
terminateWindowsDescendants
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await tree.refresh?.()
|
||||
await expect(tree.reap()).resolves.toBe('live')
|
||||
|
||||
expect(terminateWindowsDescendants).toHaveBeenCalledWith({
|
||||
...replacement,
|
||||
descendants: [...first.descendants, ...replacement.descendants],
|
||||
unidentifiedCount: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('retains the prior identity-safe snapshot when a refresh is partial', async () => {
|
||||
const child = mockChild()
|
||||
const first = {
|
||||
...snapshotOf(4243),
|
||||
descendants: [
|
||||
...snapshotOf(4243).descendants,
|
||||
{ ...snapshotOf(4243).descendants[0], pid: 4244 }
|
||||
]
|
||||
}
|
||||
const captureDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce({
|
||||
...first,
|
||||
descendants: first.descendants.slice(0, 1)
|
||||
})
|
||||
const terminateDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await tree.refresh?.()
|
||||
await tree.reap()
|
||||
|
||||
expect(terminateDescendants).toHaveBeenCalledWith(first)
|
||||
})
|
||||
|
||||
it('stops re-walking once the root is gone, however the table behaved', async () => {
|
||||
const child = mockChild()
|
||||
let exited = false
|
||||
const captureDescendants = vi.fn(async () => null)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
exited: () => exited,
|
||||
captureDescendants,
|
||||
terminateDescendants: vi.fn()
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
// An unreadable table costs the snapshot, never the kill on the live root.
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
exited = true
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
expect(captureDescendants).toHaveBeenCalledTimes(1)
|
||||
// The second attempt observes a dead root: Node has dropped the handle, so
|
||||
// there is nothing left to signal and no recycled pid to reach.
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('discards a walk that found no root instead of proving an empty tree', async () => {
|
||||
const child = mockChild()
|
||||
const captureDescendants = vi.fn(async () => ({
|
||||
rootPgid: null,
|
||||
descendants: [],
|
||||
capturedAtMs: 1
|
||||
}))
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants,
|
||||
terminateDescendants: vi.fn()
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
// A vacuous walk remains retryable while the root is live; no empty-tree
|
||||
// verdict is latched from a missing root row.
|
||||
expect(captureDescendants).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('discards a walk that raced the root exit instead of proving an empty tree', async () => {
|
||||
const child = mockChild()
|
||||
let exited = false
|
||||
const captureDescendants = vi.fn(async () => {
|
||||
exited = true
|
||||
return { rootPgid: 1, descendants: [], capturedAtMs: 1 }
|
||||
})
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
exited: () => exited,
|
||||
captureDescendants,
|
||||
terminateDescendants: vi.fn()
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('proves a childless snapshot without signalling anything', async () => {
|
||||
const child = mockChild()
|
||||
const terminateDescendants = vi.fn()
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
captureDescendants: vi.fn(async () => ({
|
||||
root: { pid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' },
|
||||
rootPgid: 1,
|
||||
descendants: [],
|
||||
capturedAtMs: 1
|
||||
})),
|
||||
terminateDescendants
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
expect(terminateDescendants).not.toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('waits for the Windows tree kill before releasing the root', async () => {
|
||||
const child = mockChild()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const terminateWindowsTree = vi.fn(() => release.promise)
|
||||
const captureDescendants = vi.fn()
|
||||
const terminateWindowsDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
captureDescendants,
|
||||
captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)),
|
||||
terminateWindowsTree,
|
||||
terminateWindowsDescendants
|
||||
})
|
||||
|
||||
const reap = tree.reap()
|
||||
await vi.waitFor(() =>
|
||||
expect(terminateWindowsTree).toHaveBeenCalledWith({
|
||||
pid: 424242,
|
||||
creationTimeMs: 1_700_000_000_001
|
||||
})
|
||||
)
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
expect(terminateWindowsDescendants).not.toHaveBeenCalled()
|
||||
release.resolve()
|
||||
await expect(reap).resolves.toBe('exited')
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243))
|
||||
expect(captureDescendants).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays unproven on Windows when taskkill fails and a descendant is still observed', async () => {
|
||||
const child = mockChild()
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)),
|
||||
terminateWindowsTree: vi.fn(async () => {
|
||||
throw new Error('taskkill: access denied')
|
||||
}),
|
||||
terminateWindowsDescendants: vi.fn(async () => 'live' as const)
|
||||
})
|
||||
|
||||
// taskkill's own outcome is not the proof; the table read after it is.
|
||||
await expect(tree.reap()).resolves.toBe('live')
|
||||
expect(tree.treeVerdict).toBe('live')
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('stays unproven on Windows when taskkill resolves but a descendant survives it', async () => {
|
||||
const child = mockChild()
|
||||
const terminateWindowsTree = vi.fn(async () => {})
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)),
|
||||
terminateWindowsTree,
|
||||
terminateWindowsDescendants: vi.fn(async () => 'live' as const)
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('live')
|
||||
expect(terminateWindowsTree).toHaveBeenCalledTimes(1)
|
||||
expect(tree.treeVerdict).toBe('live')
|
||||
})
|
||||
|
||||
it('never taskkills a Windows root that already exited, but still verifies its snapshot', async () => {
|
||||
const child = mockChild()
|
||||
let exited = false
|
||||
const terminateWindowsTree = vi.fn(async () => {})
|
||||
const terminateWindowsDescendants = vi.fn(async () => 'exited' as const)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
exited: () => exited,
|
||||
captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)),
|
||||
terminateWindowsTree,
|
||||
terminateWindowsDescendants
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
exited = true
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
// A dead root's pid may already belong to a stranger: taskkill /T /F on it
|
||||
// would take down an unrelated tree.
|
||||
expect(terminateWindowsTree).not.toHaveBeenCalled()
|
||||
expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243))
|
||||
})
|
||||
|
||||
it('treats an unreadable Windows table as unproven', async () => {
|
||||
const child = mockChild()
|
||||
const terminateWindowsDescendants = vi.fn()
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
captureWindowsDescendants: vi.fn(async () => null),
|
||||
terminateWindowsTree: vi.fn(async () => {}),
|
||||
terminateWindowsDescendants
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
expect(terminateWindowsDescendants).not.toHaveBeenCalled()
|
||||
// A host that cannot supply creation times blocks taskkill, not the root kill.
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('has nothing to reap for a child that never spawned', async () => {
|
||||
const child = mockChild(null)
|
||||
const captureDescendants = vi.fn()
|
||||
const tree = createClaudeChildTreeReaper(child, { platform: 'linux', captureDescendants })
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
expect(captureDescendants).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,366 @@
|
||||
import type { SpawnedProcess } from '../../shared/child-process/run-process'
|
||||
import {
|
||||
terminateDescendantSnapshotWithVerdict,
|
||||
type DescendantTreeVerdict
|
||||
} from '../pty-descendant-exit-verification'
|
||||
import {
|
||||
captureDescendantSnapshot,
|
||||
type DescendantSnapshot,
|
||||
type PosixProcessIdentity
|
||||
} from '../pty-descendant-termination'
|
||||
import {
|
||||
captureWindowsDescendantSnapshot,
|
||||
terminateIdentifiedWindowsProcessTree,
|
||||
verifyWindowsDescendantSnapshotExit,
|
||||
verifyWindowsProcessIdentity,
|
||||
type WindowsDescendantSnapshot,
|
||||
type WindowsProcessIdentity
|
||||
} from '../windows-descendant-exit-verification'
|
||||
import { mergeClaudeCapturedTrees, type ClaudeCapturedTree } from './claude-child-tree-snapshot'
|
||||
import { terminateClaudeRoot, terminateClaudeWindowsRoot } from './claude-child-root-termination'
|
||||
import {
|
||||
proveClaudeChildExitWithReaper,
|
||||
type ClaudeChildExitProofInput
|
||||
} from './claude-child-exit-proof-ladder'
|
||||
|
||||
/**
|
||||
* A later reap may only raise the latched verdict. An observed exit is final, and
|
||||
* a descendant seen alive at a deadline is never forgotten by a later look that
|
||||
* could not read the table: the lease gate discriminates on exactly that pair.
|
||||
*/
|
||||
const TREE_VERDICT_TRUST: Record<DescendantTreeVerdict, number> = {
|
||||
unverifiable: 0,
|
||||
live: 1,
|
||||
exited: 2
|
||||
}
|
||||
|
||||
type ReapableChild = Pick<SpawnedProcess, 'pid' | 'kill'>
|
||||
|
||||
/**
|
||||
* A walk is only admissible while the root it walked was alive. A POSIX walk
|
||||
* that found no root says so with a null pgid; either platform's walk can also
|
||||
* have raced the root's death. Both can only have missed descendants that
|
||||
* already reparented away, so neither is evidence about the tree.
|
||||
*/
|
||||
function admissibleTree(
|
||||
captured: DescendantSnapshot | WindowsDescendantSnapshot | null,
|
||||
platform: NodeJS.Platform,
|
||||
exited: boolean
|
||||
): ClaudeCapturedTree | null {
|
||||
if (!captured || exited) {
|
||||
return null
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return { platform: 'win32', tree: captured as WindowsDescendantSnapshot }
|
||||
}
|
||||
const tree = captured as DescendantSnapshot
|
||||
return tree.rootPgid === null ? null : { platform: 'posix', tree }
|
||||
}
|
||||
|
||||
export type ClaudeChildTreeReaperDeps = {
|
||||
platform?: NodeJS.Platform
|
||||
/** Whether the root's exit has been observed; only a live root can be walked. */
|
||||
exited?: () => boolean
|
||||
captureDescendants?: (rootPid: number) => Promise<DescendantSnapshot | null>
|
||||
terminateDescendants?: (snapshot: DescendantSnapshot) => Promise<DescendantTreeVerdict>
|
||||
terminateWindowsTree?: (root: WindowsProcessIdentity) => Promise<void>
|
||||
captureWindowsDescendants?: (rootPid: number) => Promise<WindowsDescendantSnapshot | null>
|
||||
terminateWindowsDescendants?: (
|
||||
snapshot: WindowsDescendantSnapshot
|
||||
) => Promise<DescendantTreeVerdict>
|
||||
/** Identity probe for the bare-pid tree kill; only Windows has one to gate. */
|
||||
verifyRootIdentity?: (root: PosixProcessIdentity | WindowsProcessIdentity) => Promise<boolean>
|
||||
}
|
||||
|
||||
export type ClaudeChildTreeReaper = {
|
||||
/**
|
||||
* Snapshot the root's live descendants. The moment the root dies they reparent
|
||||
* and no table walk can find them again, so this has to run before anything
|
||||
* gives the root a reason to leave. Held once; later calls are no-ops.
|
||||
*/
|
||||
capture(): Promise<void>
|
||||
/** Refresh a live root's snapshot at the close boundary; a failed refresh keeps the prior proof. */
|
||||
refresh?: () => Promise<void>
|
||||
/**
|
||||
* Kill the child's whole tree and report what the bounded verification
|
||||
* observed. Concurrent calls share one reap, and a later call re-verifies the
|
||||
* same snapshot rather than trusting a root that has since died on its own.
|
||||
*/
|
||||
reap(): Promise<DescendantTreeVerdict>
|
||||
/**
|
||||
* `unverifiable` until a reap observes otherwise. `exited` is the only verdict
|
||||
* that lets a close release the lease; `live` names a descendant that was seen
|
||||
* still running, which no later caller may collapse into "unknown".
|
||||
*/
|
||||
readonly treeVerdict: DescendantTreeVerdict
|
||||
}
|
||||
|
||||
/**
|
||||
* The same shared primitives the Codex structured provider composes: a raw
|
||||
* pipe child owns no PTY job, so there is nothing for the PTY job sweep to
|
||||
* terminate on Windows and no unref'd timer is allowed to outlive the proof.
|
||||
*
|
||||
* The proof is unproven by default. `treeVerdict` is assigned in exactly one
|
||||
* place, from the verdict of `judgeTree`, so a code path that never reaches a
|
||||
* verification cannot report the tree gone by omission.
|
||||
*/
|
||||
export function createClaudeChildTreeReaper(
|
||||
child: ReapableChild,
|
||||
deps: ClaudeChildTreeReaperDeps = {}
|
||||
): ClaudeChildTreeReaper {
|
||||
const platform = deps.platform ?? process.platform
|
||||
const exited = deps.exited ?? (() => false)
|
||||
// Undefined until captured; null when no admissible snapshot exists — the root
|
||||
// was already gone, or the table could not be read while it was alive — which
|
||||
// no later read can make up for.
|
||||
let snapshot: ClaudeCapturedTree | null | undefined
|
||||
let capturing: Promise<void> | null = null
|
||||
let refreshing: Promise<void> | null = null
|
||||
let queuedRefresh: Promise<void> | null = null
|
||||
let inFlight: Promise<DescendantTreeVerdict> | null = null
|
||||
let treeVerdict: DescendantTreeVerdict = 'unverifiable'
|
||||
|
||||
// Consulted only on win32: POSIX signals descendants by revalidated identity
|
||||
// and reaches the root solely through Node's handle, so neither needs a probe.
|
||||
const verifyRoot =
|
||||
deps.verifyRootIdentity ??
|
||||
((root: PosixProcessIdentity | WindowsProcessIdentity) =>
|
||||
verifyWindowsProcessIdentity(root as WindowsProcessIdentity))
|
||||
|
||||
function captureOnce(): Promise<void> {
|
||||
if (refreshing) {
|
||||
const pending = refreshing
|
||||
return pending.then(() => queuedRefresh ?? undefined)
|
||||
}
|
||||
if (snapshot !== undefined) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (capturing) {
|
||||
const pending = capturing
|
||||
return pending.then(() => queuedRefresh ?? undefined)
|
||||
}
|
||||
const rootPid = child.pid
|
||||
if (!rootPid || exited()) {
|
||||
// Only the root's death makes a missing snapshot final: its descendants
|
||||
// have reparented, and no later walk can reach them.
|
||||
snapshot = exited() ? null : snapshot
|
||||
return Promise.resolve()
|
||||
}
|
||||
const capture =
|
||||
platform === 'win32'
|
||||
? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot)
|
||||
: (deps.captureDescendants ?? captureDescendantSnapshot)
|
||||
capturing = capture(rootPid)
|
||||
.catch(() => null)
|
||||
.then((captured) => {
|
||||
// A walk that found no root, or that raced the root's death, can only
|
||||
// have missed descendants that already reparented away. A table that
|
||||
// could not be read in time is not an answer at all: while the root
|
||||
// still lives the walk is simply retried, rather than latching a failed
|
||||
// read as proof that there was nothing to find.
|
||||
const rootExited = exited()
|
||||
const tree = admissibleTree(captured, platform, rootExited)
|
||||
if (tree) {
|
||||
snapshot = tree
|
||||
} else if (rootExited) {
|
||||
// Once the root has exited its descendants may have reparented; no
|
||||
// later table read can make an absent snapshot safe to signal.
|
||||
snapshot = null
|
||||
} else {
|
||||
// A failed read or a walk that did not observe the live root is
|
||||
// retryable while the root remains alive. Never latch a vacuous null.
|
||||
snapshot = undefined
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
capturing = null
|
||||
})
|
||||
return capturing
|
||||
}
|
||||
|
||||
function startRefresh(): Promise<void> {
|
||||
if (exited()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const rootPid = child.pid
|
||||
if (!rootPid) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const capture =
|
||||
platform === 'win32'
|
||||
? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot)
|
||||
: (deps.captureDescendants ?? captureDescendantSnapshot)
|
||||
const operation = (async () => {
|
||||
const captured = await capture(rootPid).catch(() => null)
|
||||
if (exited()) {
|
||||
return
|
||||
}
|
||||
const tree = admissibleTree(captured, platform, false)
|
||||
if (!tree) {
|
||||
return
|
||||
}
|
||||
if (snapshot === undefined) {
|
||||
snapshot = tree
|
||||
return
|
||||
}
|
||||
if (snapshot !== null) {
|
||||
// A merge that returns null saw a same-PID identity change: a
|
||||
// recycle/replace decision, not an absent descendant, so no row here may
|
||||
// be signalled from its number. Only the descendant evidence is lost —
|
||||
// the root still leaves through the handle no recycled pid can reach.
|
||||
snapshot = mergeClaudeCapturedTrees(snapshot, tree)
|
||||
}
|
||||
// Keep an earlier admissible snapshot when this close-boundary read fails;
|
||||
// it remains the only identity-safe evidence after root exit.
|
||||
})()
|
||||
refreshing = operation
|
||||
const clearRefreshing = (): void => {
|
||||
if (refreshing === operation) {
|
||||
refreshing = null
|
||||
}
|
||||
}
|
||||
void operation.then(clearRefreshing, clearRefreshing)
|
||||
return operation
|
||||
}
|
||||
|
||||
function queueRefreshAfter(pending: Promise<void>): Promise<void> {
|
||||
if (queuedRefresh) {
|
||||
return queuedRefresh
|
||||
}
|
||||
const operation = pending.then(() => {
|
||||
if (exited()) {
|
||||
return
|
||||
}
|
||||
return startRefresh()
|
||||
})
|
||||
queuedRefresh = operation
|
||||
const clearQueuedRefresh = (): void => {
|
||||
if (queuedRefresh === operation) {
|
||||
queuedRefresh = null
|
||||
}
|
||||
}
|
||||
void operation.then(clearQueuedRefresh, clearQueuedRefresh)
|
||||
return operation
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const pending = capturing ?? refreshing
|
||||
if (pending) {
|
||||
await queueRefreshAfter(pending)
|
||||
return
|
||||
}
|
||||
if (queuedRefresh) {
|
||||
await queuedRefresh
|
||||
return
|
||||
}
|
||||
try {
|
||||
await startRefresh()
|
||||
} catch {
|
||||
// A refresh is advisory; capture failures leave the prior proof intact.
|
||||
}
|
||||
}
|
||||
|
||||
/** The only source of a tree verdict: every `exited` here is an observation. */
|
||||
async function judgeTree(): Promise<DescendantTreeVerdict> {
|
||||
const killRoot = (): boolean => terminateClaudeRoot({ child, exited })
|
||||
const rootPid = child.pid
|
||||
if (!rootPid) {
|
||||
// Never spawned, so the OS never created a tree to orphan.
|
||||
return 'exited'
|
||||
}
|
||||
await captureOnce()
|
||||
if (platform === 'win32') {
|
||||
// Why taskkill's own outcome is never the verdict: it resolves identically
|
||||
// on a timeout, an access denial, a recycled root and a real kill.
|
||||
const { rootVerified } = await terminateClaudeWindowsRoot({
|
||||
snapshot: snapshot?.platform === 'win32' ? snapshot.tree : null,
|
||||
exited,
|
||||
verifyRoot: (root) => verifyRoot(root),
|
||||
terminateTree: (root) =>
|
||||
deps.terminateWindowsTree
|
||||
? deps.terminateWindowsTree(root)
|
||||
: terminateIdentifiedWindowsProcessTree(root, {
|
||||
ownsRoot: () => !exited()
|
||||
}).then(() => undefined),
|
||||
killRoot
|
||||
})
|
||||
if (!rootVerified && !exited()) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
return snapshot?.platform === 'win32'
|
||||
? await (deps.terminateWindowsDescendants ?? verifyWindowsDescendantSnapshotExit)(
|
||||
snapshot.tree
|
||||
)
|
||||
: 'unverifiable'
|
||||
}
|
||||
if (snapshot?.platform !== 'posix') {
|
||||
killRoot()
|
||||
return 'unverifiable'
|
||||
}
|
||||
if (snapshot.tree.descendants.length === 0) {
|
||||
// Read while the root was alive and childless: a later table read has no
|
||||
// row it could match, so it would add nothing to this observation.
|
||||
killRoot()
|
||||
return 'exited'
|
||||
}
|
||||
// Why the root is killed while verification is already running, and never
|
||||
// SIGSTOPped first the way the Codex non-group path does: measured on macOS, a
|
||||
// killed child of a stopped parent stays a zombie row in ps with its lstart
|
||||
// and pgid intact, so verification cannot pass until the root is dead. The
|
||||
// descendants are signalled by the verifier as soon as it revalidates their
|
||||
// identities; the root's death then reparents any zombies to init, which
|
||||
// reaps them. After a root exit the kill is a no-op: Node drops the handle
|
||||
// on exit and never signals a possibly recycled pid.
|
||||
const verdictPromise = deps.terminateDescendants
|
||||
? deps.terminateDescendants(snapshot.tree)
|
||||
: terminateDescendantSnapshotWithVerdict(snapshot.tree, {
|
||||
requireIdentityBeforeSignal: true
|
||||
})
|
||||
killRoot()
|
||||
// What the verification observed is the verdict: a kill that reports no
|
||||
// signal means the handle was already gone, never that the tree survived.
|
||||
return verdictPromise
|
||||
}
|
||||
|
||||
return {
|
||||
capture: captureOnce,
|
||||
refresh,
|
||||
reap() {
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
const attempt = judgeTree()
|
||||
.catch((): DescendantTreeVerdict => 'unverifiable')
|
||||
.then((verdict) => {
|
||||
treeVerdict =
|
||||
TREE_VERDICT_TRUST[verdict] > TREE_VERDICT_TRUST[treeVerdict] ? verdict : treeVerdict
|
||||
return verdict
|
||||
})
|
||||
inFlight = attempt
|
||||
void attempt.finally(() => {
|
||||
if (inFlight === attempt) {
|
||||
inFlight = null
|
||||
}
|
||||
})
|
||||
return attempt
|
||||
},
|
||||
get treeVerdict() {
|
||||
return treeVerdict
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orca's own shutdown ladder on the child it spawned, kept because the SDK's
|
||||
* close path returns no proof and Orca never releases a lease on an assumed exit.
|
||||
*
|
||||
* Resolves true only after the child actually emitted exit and its snapshotted
|
||||
* descendants were observed gone; false is unproven. A root that left on its
|
||||
* own before a snapshot could be armed stays unproven: its descendants had
|
||||
* already reparented out of reach when the ladder first looked.
|
||||
*/
|
||||
export function proveClaudeChildExit(input: ClaudeChildExitProofInput): Promise<boolean> {
|
||||
return proveClaudeChildExitWithReaper(input, () =>
|
||||
createClaudeChildTreeReaper(input.child, { exited: input.exited })
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
|
||||
/**
|
||||
* Keep the agent SDK on the structured-Claude side of the toggle.
|
||||
*
|
||||
* A user who never leaves the terminal/TUI Claude path must not pay for the SDK:
|
||||
* importing it evaluates a package that rewrites
|
||||
* `process.env.NoDefaultCurrentDirectoryInExePath`, changing how Windows resolves
|
||||
* executables for every later subprocess, and a missing or incompatible install
|
||||
* would take normal runtime startup down with it. The ordinary
|
||||
* `OrcaRuntimeService` graph reaches the Claude transport module, so only a
|
||||
* deferred import keeps that boundary — and only a walk of the real import graph
|
||||
* keeps the next static import from quietly restoring it.
|
||||
*/
|
||||
const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk'
|
||||
const REPO_ROOT = resolve(__dirname, '..', '..', '..')
|
||||
|
||||
/** The Electron main entry: everything the app loads before any session exists. */
|
||||
const ROOT = 'src/main/index.ts'
|
||||
/** Proof the walk goes all the way into the Claude transport rather than stopping short. */
|
||||
const TRANSPORT_MODULE = 'src/main/claude/claude-stream-json-connection.ts'
|
||||
|
||||
/**
|
||||
* Static, value-carrying specifiers only, read statement by statement so a
|
||||
* multi-line `import { ... } from '...'` counts. `import type` is erased before
|
||||
* the module ever loads and a bare `import(...)` is the deferral this guards, so
|
||||
* neither is an edge the runtime traverses at load time.
|
||||
*/
|
||||
const STATEMENT_START = /^\s*(?:import|export)\b/
|
||||
const TYPE_ONLY = /^\s*(?:import|export)\s+type\b/
|
||||
const FROM_SPECIFIER = /(?:^|\s)from\s*['"]([^'"]+)['"]/
|
||||
const SIDE_EFFECT_IMPORT = /^\s*import\s*['"]([^'"]+)['"]/
|
||||
/** An import statement never spans more lines than its longest specifier list. */
|
||||
const MAX_STATEMENT_LINES = 60
|
||||
|
||||
function readSpecifiers(source: string): string[] {
|
||||
const lines = source.split('\n')
|
||||
const found: string[] = []
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const first = lines[index] as string
|
||||
if (!STATEMENT_START.test(first) || TYPE_ONLY.test(first)) {
|
||||
continue
|
||||
}
|
||||
const sideEffect = SIDE_EFFECT_IMPORT.exec(first)
|
||||
if (sideEffect) {
|
||||
found.push(sideEffect[1] as string)
|
||||
continue
|
||||
}
|
||||
for (let scan = index; scan < Math.min(lines.length, index + MAX_STATEMENT_LINES); scan += 1) {
|
||||
if (scan > index && STATEMENT_START.test(lines[scan] as string)) {
|
||||
break
|
||||
}
|
||||
const specifier = FROM_SPECIFIER.exec(lines[scan] as string)
|
||||
if (specifier) {
|
||||
found.push(specifier[1] as string)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Resolve a relative specifier the way the bundler does; unresolvable means not a module. */
|
||||
function resolveRelative(fromFile: string, specifier: string): string | null {
|
||||
const base = join(dirname(fromFile), specifier)
|
||||
for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function walkStaticImports(rootFile: string): { visited: Set<string>; sdkImporters: string[] } {
|
||||
const visited = new Set<string>()
|
||||
const sdkImporters: string[] = []
|
||||
const queue = [resolve(REPO_ROOT, rootFile)]
|
||||
while (queue.length > 0) {
|
||||
const file = queue.pop() as string
|
||||
const key = relative(REPO_ROOT, file).split('\\').join('/')
|
||||
if (visited.has(key)) {
|
||||
continue
|
||||
}
|
||||
visited.add(key)
|
||||
for (const specifier of readSpecifiers(readFileSync(file, 'utf8'))) {
|
||||
if (specifier === SDK_PACKAGE || specifier.startsWith(`${SDK_PACKAGE}/`)) {
|
||||
sdkImporters.push(key)
|
||||
continue
|
||||
}
|
||||
if (!specifier.startsWith('.')) {
|
||||
continue
|
||||
}
|
||||
const target = resolveRelative(file, specifier)
|
||||
if (target) {
|
||||
queue.push(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { visited, sdkImporters }
|
||||
}
|
||||
|
||||
describe('claude agent SDK import boundary', () => {
|
||||
const walk = walkStaticImports(ROOT)
|
||||
|
||||
it('walks a graph deep enough to reach the Claude transport', () => {
|
||||
// Without this the guard passes for the wrong reason the moment the walk breaks.
|
||||
expect(walk.visited.size).toBeGreaterThan(500)
|
||||
expect([...walk.visited]).toContain(TRANSPORT_MODULE)
|
||||
})
|
||||
|
||||
it('never reaches the SDK through a static import from the main entry', () => {
|
||||
expect(
|
||||
walk.sdkImporters,
|
||||
`${SDK_PACKAGE} must stay behind the structured-Claude boundary. Load it with a deferred import inside the session path instead.`
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves the Windows executable-search environment alone when the runtime loads', async () => {
|
||||
// A vitest file runs in its own fork, so this is a clean process; the ambient
|
||||
// value is cleared first because the developer's own shell may carry one.
|
||||
delete process.env.NoDefaultCurrentDirectoryInExePath
|
||||
await import('../runtime/structured-agent-session-runtime')
|
||||
|
||||
expect(process.env.NoDefaultCurrentDirectoryInExePath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still lets the SDK set it, so the guard above is not measuring nothing', async () => {
|
||||
// A separate process, not this fork: the assertion has to be about a first
|
||||
// evaluation of the package, which a cached module registry cannot give.
|
||||
const { NoDefaultCurrentDirectoryInExePath: _cleared, ...env } = process.env
|
||||
const probe = spawnProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
'-e',
|
||||
`import(${JSON.stringify(SDK_PACKAGE)}).then(() => console.log(String(process.env.NoDefaultCurrentDirectoryInExePath)))`
|
||||
],
|
||||
cwd: REPO_ROOT,
|
||||
env: env as Record<string, string>,
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
const observed = await new Promise<string>((settle) => {
|
||||
let output = ''
|
||||
probe.stdout?.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
output += chunk
|
||||
})
|
||||
probe.once('close', () => settle(output.trim()))
|
||||
})
|
||||
|
||||
expect(observed).toBe('1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { resolveSpawn, type spawnProcess } from '../../shared/child-process/run-process'
|
||||
import type { ProcessSpec } from '../../shared/child-process/process-spec'
|
||||
import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn'
|
||||
|
||||
type FakeChild = EventEmitter & {
|
||||
pid: number
|
||||
stdin: PassThrough
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function fakeSpawn() {
|
||||
const child = new EventEmitter() as FakeChild
|
||||
child.pid = 4321
|
||||
child.stdin = new PassThrough()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn(() => true)
|
||||
const specs: ProcessSpec[] = []
|
||||
const spawnImpl = ((spec: ProcessSpec) => {
|
||||
specs.push(spec)
|
||||
return child
|
||||
}) as unknown as typeof spawnProcess
|
||||
return { child, spawnImpl, specs }
|
||||
}
|
||||
|
||||
function sdkOptions(overrides: Partial<SdkSpawnOptions> = {}): SdkSpawnOptions {
|
||||
return {
|
||||
command: '/usr/local/bin/claude',
|
||||
args: ['--output-format', 'stream-json'],
|
||||
cwd: '/work/repo',
|
||||
env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one', UNSET: undefined },
|
||||
signal: new AbortController().signal,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('claude agent SDK process spawn', () => {
|
||||
it('routes the SDK spawn through Orca and retains the pid the lease adjudicates on', () => {
|
||||
const process = fakeSpawn()
|
||||
const spawn = createClaudeCodeProcessSpawn(process.spawnImpl)
|
||||
|
||||
expect(spawn.pid).toBeUndefined()
|
||||
expect(spawn.child).toBeNull()
|
||||
const child = spawn.spawn(sdkOptions())
|
||||
|
||||
expect(child).toBe(process.child)
|
||||
expect(spawn.child).toBe(process.child)
|
||||
expect(spawn.pid).toBe(4321)
|
||||
expect(process.specs[0]).toEqual({
|
||||
program: '/usr/local/bin/claude',
|
||||
args: ['--output-format', 'stream-json'],
|
||||
cwd: '/work/repo',
|
||||
env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the child out of the SDK abort path so exit proof stays Orca-owned', () => {
|
||||
const process = fakeSpawn()
|
||||
const controller = new AbortController()
|
||||
createClaudeCodeProcessSpawn(process.spawnImpl).spawn(sdkOptions({ signal: controller.signal }))
|
||||
|
||||
// Node's spawn({signal}) kills the child on abort; Orca's ladder must be the
|
||||
// only thing that can end this process, or close() would report an assumed exit.
|
||||
expect(process.specs[0]).not.toHaveProperty('signal')
|
||||
})
|
||||
|
||||
it('drains stderr into a bounded tail so an exit error still carries it', async () => {
|
||||
const process = fakeSpawn()
|
||||
const spawn = createClaudeCodeProcessSpawn(process.spawnImpl)
|
||||
spawn.spawn(sdkOptions())
|
||||
|
||||
process.child.stderr.write('x'.repeat(9000))
|
||||
process.child.stderr.write('claude: not signed in')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(spawn.stderrTail).toMatch(/claude: not signed in$/)
|
||||
expect(spawn.stderrTail.length).toBe(8192)
|
||||
})
|
||||
|
||||
it('hands a Windows .cmd shim to Orca\u2019s argument encoder', () => {
|
||||
const process = fakeSpawn()
|
||||
createClaudeCodeProcessSpawn(process.spawnImpl).spawn(
|
||||
sdkOptions({
|
||||
command: 'C:\\Users\\dev\\AppData\\npm\\claude.cmd',
|
||||
args: ['--setting-sources=user,project,local', '--session-id', 'a b&c']
|
||||
})
|
||||
)
|
||||
|
||||
// The spec the spawner builds is what Orca's Windows branch encodes; the SDK's
|
||||
// own spawn would hand `.cmd` straight to Node and mangle the argument.
|
||||
const resolved = resolveSpawn(process.specs[0] as ProcessSpec, 'win32')
|
||||
expect(resolved.file.toLowerCase()).toContain('cmd.exe')
|
||||
expect(resolved.options.windowsVerbatimArguments).toBe(true)
|
||||
expect(resolved.args).toHaveLength(1)
|
||||
// `/v:off` plus the quoted argument is what keeps `&` from splitting the line.
|
||||
expect(resolved.args[0]).toContain('/v:off')
|
||||
expect(resolved.args[0]).toContain('"a b&c"')
|
||||
expect(resolved.args[0]).toContain('"--setting-sources=user,project,local"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
|
||||
/** Derived rather than imported: only src/shared/child-process may name node:child_process. */
|
||||
type ClaudeCodeChild = ReturnType<typeof spawnProcess>
|
||||
|
||||
const STDERR_TAIL_MAX_BYTES = 8192
|
||||
|
||||
export type ClaudeCodeProcessSpawn = {
|
||||
/** Pass as the SDK's `spawnClaudeCodeProcess`; the SDK never learns the pid because it never owns it. */
|
||||
spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild
|
||||
/** The retained child, so Orca keeps its own tree-kill and exit-proof ladder. Null until the SDK spawns. */
|
||||
readonly child: ClaudeCodeChild | null
|
||||
/** Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn token. */
|
||||
readonly pid: number | undefined
|
||||
readonly stderrTail: string
|
||||
}
|
||||
|
||||
function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value !== undefined) {
|
||||
next[key] = value
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Orca supplies the Claude Code child rather than letting the SDK spawn it.
|
||||
*
|
||||
* Two independent reasons: the SDK's `SpawnedProcess` has no pid, and Orca's
|
||||
* spawner is the only path that encodes `.cmd` arguments safely on Windows.
|
||||
*/
|
||||
export function createClaudeCodeProcessSpawn(
|
||||
spawnImpl: typeof spawnProcess = spawnProcess
|
||||
): ClaudeCodeProcessSpawn {
|
||||
let child: ClaudeCodeChild | null = null
|
||||
let stderrTail = ''
|
||||
return {
|
||||
spawn: (options) => {
|
||||
// Why `options.signal` is dropped: it would let the SDK kill the child outside
|
||||
// Orca's ladder, and close() may never report an exit it did not observe.
|
||||
const spawned = spawnImpl({
|
||||
program: options.command,
|
||||
args: [...options.args],
|
||||
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
||||
env: definedEnv(options.env),
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
child = spawned
|
||||
// The SDK drains stderr only for its own local spawn, so a custom spawner must:
|
||||
// otherwise the child blocks on a full pipe and exit errors lose their tail.
|
||||
spawned.stderr.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES)
|
||||
})
|
||||
return spawned
|
||||
},
|
||||
get child() {
|
||||
return child
|
||||
},
|
||||
get pid() {
|
||||
return child?.pid
|
||||
},
|
||||
get stderrTail() {
|
||||
return stderrTail
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SpawnedProcess } from '../../shared/child-process/run-process'
|
||||
import type { DescendantSnapshot } from '../pty-descendant-termination'
|
||||
import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification'
|
||||
import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof'
|
||||
import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot'
|
||||
|
||||
const ROOT_PID = 424242
|
||||
const ROOT_STARTED_AT = 'Mon Jan 1 00:00:00 2026'
|
||||
const ROOT_FORK_MS = Date.parse(ROOT_STARTED_AT)
|
||||
|
||||
function mockChild(): EventEmitter &
|
||||
Pick<SpawnedProcess, 'pid' | 'kill' | 'stdin'> & { kill: ReturnType<typeof vi.fn> } {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
pid: ROOT_PID,
|
||||
stdin: new PassThrough(),
|
||||
kill: vi.fn(() => true)
|
||||
}) as never
|
||||
}
|
||||
|
||||
function posixSnapshot(input: {
|
||||
capturedAtMs: number
|
||||
descendants?: DescendantSnapshot['descendants']
|
||||
}): DescendantSnapshot {
|
||||
return {
|
||||
root: { pid: ROOT_PID, startedAt: ROOT_STARTED_AT },
|
||||
rootPgid: ROOT_PID,
|
||||
descendants: input.descendants ?? [],
|
||||
capturedAtMs: input.capturedAtMs
|
||||
}
|
||||
}
|
||||
|
||||
function windowsSnapshot(capturedAtMs = 1): WindowsDescendantSnapshot {
|
||||
return {
|
||||
root: { pid: ROOT_PID, creationTimeMs: 1_700_000_000_001 },
|
||||
descendants: [{ pid: 4243, creationTimeMs: 1_700_000_000_000 }],
|
||||
unidentifiedCount: 0,
|
||||
capturedAtMs
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude root kill fallback', () => {
|
||||
it('kills the root when the first capture landed in the fork second', async () => {
|
||||
// The production POSIX verifier declines a root born in its capture second,
|
||||
// and that verdict must not cost the tree the kill on Node's own handle.
|
||||
const child = mockChild()
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
exited: () => false,
|
||||
captureDescendants: vi.fn(async () => posixSnapshot({ capturedAtMs: ROOT_FORK_MS + 300 }))
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('kills the root after a recycled descendant pid voided the snapshot', async () => {
|
||||
const child = mockChild()
|
||||
const captureDescendants = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
posixSnapshot({
|
||||
capturedAtMs: ROOT_FORK_MS + 5_000,
|
||||
descendants: [{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: ROOT_STARTED_AT }]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
posixSnapshot({
|
||||
capturedAtMs: ROOT_FORK_MS + 6_000,
|
||||
descendants: [
|
||||
{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: 'Mon Jan 1 00:00:30 2026' }
|
||||
]
|
||||
})
|
||||
)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
exited: () => false,
|
||||
captureDescendants,
|
||||
terminateDescendants: vi.fn(async () => 'exited' as const),
|
||||
verifyRootIdentity: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
await tree.capture()
|
||||
await tree.refresh?.()
|
||||
// The descendant evidence is rightly discarded; the root's never was in doubt.
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('keeps an observed live descendant when the root identity probe declined', async () => {
|
||||
const child = mockChild()
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
exited: () => false,
|
||||
captureDescendants: vi.fn(async () =>
|
||||
posixSnapshot({
|
||||
capturedAtMs: ROOT_FORK_MS + 5_000,
|
||||
descendants: [{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: ROOT_STARTED_AT }]
|
||||
})
|
||||
),
|
||||
terminateDescendants: vi.fn(async () => 'live' as const),
|
||||
verifyRootIdentity: vi.fn(async () => false)
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('live')
|
||||
expect(tree.treeVerdict).toBe('live')
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('reports a Windows taskkill that worked as exited, not unverifiable', async () => {
|
||||
const child = mockChild()
|
||||
// Probe 1 gates taskkill; a later probe correctly finds the root already dead.
|
||||
const verifyRootIdentity = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false)
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
exited: () => false,
|
||||
captureWindowsDescendants: vi.fn(async () => windowsSnapshot()),
|
||||
terminateWindowsTree: vi.fn(async () => {}),
|
||||
terminateWindowsDescendants: vi.fn(async () => 'exited' as const),
|
||||
verifyRootIdentity
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('exited')
|
||||
})
|
||||
|
||||
it('kills the root when no POSIX snapshot could be read', async () => {
|
||||
const child = mockChild()
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
exited: () => false,
|
||||
captureDescendants: vi.fn(async () => null),
|
||||
terminateDescendants: vi.fn()
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('kills the root when the Windows process table is unreadable', async () => {
|
||||
const child = mockChild()
|
||||
const terminateWindowsTree = vi.fn(async () => {})
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'win32',
|
||||
exited: () => false,
|
||||
captureWindowsDescendants: vi.fn(async () => null),
|
||||
terminateWindowsTree,
|
||||
terminateWindowsDescendants: vi.fn()
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
// No identity means no bare-pid tree kill, but the owned handle is still ours.
|
||||
expect(terminateWindowsTree).not.toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('never signals a root the reaper already saw exit', async () => {
|
||||
const child = mockChild()
|
||||
const tree = createClaudeChildTreeReaper(child, {
|
||||
platform: 'linux',
|
||||
exited: () => true,
|
||||
captureDescendants: vi.fn(async () => null)
|
||||
})
|
||||
|
||||
await expect(tree.reap()).resolves.toBe('unverifiable')
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('chains per-pid Windows boundaries across a second merge', async () => {
|
||||
const first = windowsSnapshot(1_000)
|
||||
const second: WindowsDescendantSnapshot = {
|
||||
...windowsSnapshot(2_000),
|
||||
descendants: [
|
||||
{ pid: 4243, creationTimeMs: 1_700_000_000_000 },
|
||||
{ pid: 4244, creationTimeMs: 1_700_000_000_002 }
|
||||
]
|
||||
}
|
||||
const third: WindowsDescendantSnapshot = { ...second, capturedAtMs: 3_000 }
|
||||
|
||||
const merged = mergeClaudeCapturedTrees(
|
||||
{ platform: 'win32', tree: first },
|
||||
{ platform: 'win32', tree: second }
|
||||
)
|
||||
expect(merged?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 })
|
||||
const rechained = mergeClaudeCapturedTrees(merged!, { platform: 'win32', tree: third })
|
||||
|
||||
expect(rechained?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue'
|
||||
|
||||
/**
|
||||
* The SDK's input pump is `for await (const frame of prompt) { await transport.write(frame) }`.
|
||||
* A rejected write — or an abort — ends that loop abruptly, which calls the
|
||||
* generator's `return()`. Everything below drives that exact shape, because the
|
||||
* frame the pump already pulled is the one nothing else can reach.
|
||||
*/
|
||||
const frame = (text: string): SDKUserMessage =>
|
||||
({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text }] }
|
||||
}) as unknown as SDKUserMessage
|
||||
|
||||
const settled = (promise: Promise<void>): Promise<'settled' | 'pending'> =>
|
||||
Promise.race([
|
||||
promise.then(
|
||||
() => 'settled' as const,
|
||||
() => 'settled' as const
|
||||
),
|
||||
new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 100))
|
||||
])
|
||||
|
||||
describe('claude user message queue', () => {
|
||||
it('rejects the frame the SDK pulled but abandoned without writing', async () => {
|
||||
const queue = createClaudeUserMessageQueue()
|
||||
const pump = queue.messages[Symbol.asyncIterator]()
|
||||
const sent = queue.push(frame('hello'))
|
||||
|
||||
await pump.next()
|
||||
await pump.return?.(undefined)
|
||||
|
||||
await expect(settled(sent)).resolves.toBe('settled')
|
||||
await expect(sent).rejects.toThrow(
|
||||
'claude stream-json input ended before the frame was written'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an in-flight frame from fail() when the SDK never resumes the pump', async () => {
|
||||
const queue = createClaudeUserMessageQueue()
|
||||
const pump = queue.messages[Symbol.asyncIterator]()
|
||||
const sent = queue.push(frame('hello'))
|
||||
|
||||
await pump.next()
|
||||
queue.fail(new Error('claude stream-json exited: child died'))
|
||||
|
||||
await expect(settled(sent)).resolves.toBe('settled')
|
||||
await expect(sent).rejects.toThrow('claude stream-json exited: child died')
|
||||
})
|
||||
|
||||
it('still settles a written frame only once the pump asks for the next one', async () => {
|
||||
const queue = createClaudeUserMessageQueue()
|
||||
const pump = queue.messages[Symbol.asyncIterator]()
|
||||
const sent = queue.push(frame('hello'))
|
||||
|
||||
const pulled = await pump.next()
|
||||
expect(pulled.value).toMatchObject({ type: 'user' })
|
||||
// The write proof is the pump coming back for more, exactly as before.
|
||||
await expect(settled(sent)).resolves.toBe('pending')
|
||||
void pump.next()
|
||||
await expect(sent).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
|
||||
type QueuedMessage = {
|
||||
message: SDKUserMessage
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
export type ClaudeUserMessageQueue = {
|
||||
/** The SDK's streaming-input prompt; it stays open until `end`. */
|
||||
messages: AsyncIterable<SDKUserMessage>
|
||||
/** Resolves once the SDK has finished writing the frame to the child. */
|
||||
push: (message: SDKUserMessage) => Promise<void>
|
||||
/** Reject every unwritten frame, in-flight included; a caller waiting on a send must not hang past the exit. */
|
||||
fail: (error: Error) => void
|
||||
end: () => void
|
||||
}
|
||||
|
||||
/** The rejection an abandoned frame carries when nothing else has named a cause yet. */
|
||||
const UNWRITTEN_FRAME_MESSAGE = 'claude stream-json input ended before the frame was written'
|
||||
|
||||
export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue {
|
||||
const queued: QueuedMessage[] = []
|
||||
// The frame the SDK has taken but not yet acknowledged. It is out of `queued`,
|
||||
// so it is unreachable from anywhere else and would otherwise never settle.
|
||||
let inFlight: QueuedMessage | null = null
|
||||
let wake: (() => void) | null = null
|
||||
let ended = false
|
||||
let failure: Error | null = null
|
||||
const notify = (): void => {
|
||||
wake?.()
|
||||
wake = null
|
||||
}
|
||||
const rejectInFlight = (error: Error): void => {
|
||||
const abandoned = inFlight
|
||||
inFlight = null
|
||||
abandoned?.reject(error)
|
||||
}
|
||||
|
||||
async function* drain(): AsyncGenerator<SDKUserMessage> {
|
||||
for (;;) {
|
||||
const next = queued.shift()
|
||||
if (next) {
|
||||
inFlight = next
|
||||
let written = false
|
||||
try {
|
||||
yield next.message
|
||||
written = true
|
||||
} finally {
|
||||
// The SDK's input pump abandons this iterator when its
|
||||
// `await transport.write(...)` rejects or the query aborts, and the code
|
||||
// after a `yield` never runs on that path. Settling here is the only
|
||||
// place a frame it already took can be reached.
|
||||
if (written) {
|
||||
inFlight = null
|
||||
// Resumed only after the SDK's `await transport.write(...)` settled, so this
|
||||
// is the same "the frame reached the child" proof the hand-rolled write gave.
|
||||
next.resolve()
|
||||
} else {
|
||||
rejectInFlight(failure ?? new Error(UNWRITTEN_FRAME_MESSAGE))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ended || failure) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: drain(),
|
||||
push: (message) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (failure) {
|
||||
reject(failure)
|
||||
return
|
||||
}
|
||||
queued.push({ message, resolve, reject })
|
||||
notify()
|
||||
}),
|
||||
fail: (error) => {
|
||||
failure ??= error
|
||||
for (const entry of queued.splice(0)) {
|
||||
entry.reject(error)
|
||||
}
|
||||
// A pump that never resumes cannot run the generator's cleanup, so the
|
||||
// exit path has to reach the in-flight frame itself.
|
||||
rejectInFlight(error)
|
||||
notify()
|
||||
},
|
||||
end: () => {
|
||||
ended = true
|
||||
notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SpawnedProcess } from '../../shared/child-process/run-process'
|
||||
import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline'
|
||||
import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof'
|
||||
|
||||
const GRACEFUL_EXIT_MS = 1_500
|
||||
const FORCED_EXIT_MS = 1_000
|
||||
|
||||
export type ClaudeChildExitProofInput = {
|
||||
child: Pick<SpawnedProcess, 'pid' | 'kill' | 'stdin'>
|
||||
exitPromise: Promise<void>
|
||||
exited: () => boolean
|
||||
tree?: ClaudeChildTreeReaper
|
||||
}
|
||||
|
||||
export async function proveClaudeChildExitWithReaper(
|
||||
input: ClaudeChildExitProofInput,
|
||||
createTree: () => ClaudeChildTreeReaper
|
||||
): Promise<boolean> {
|
||||
const tree = input.tree ?? createTree()
|
||||
// Arm before stdin closes: only a live root can identify its descendants.
|
||||
await tree.capture()
|
||||
try {
|
||||
input.child.stdin?.end()
|
||||
} catch {
|
||||
// The reap below still owns the process.
|
||||
}
|
||||
let reaped = false
|
||||
if (!input.exited()) {
|
||||
await waitForProcessExitUntil(input.exitPromise, GRACEFUL_EXIT_MS)
|
||||
if (!input.exited()) {
|
||||
reaped = true
|
||||
await tree.refresh?.()
|
||||
await tree.reap()
|
||||
await waitForProcessExitUntil(input.exitPromise, FORCED_EXIT_MS)
|
||||
}
|
||||
}
|
||||
if (!reaped && input.exited() && tree.treeVerdict !== 'exited') {
|
||||
await tree.reap()
|
||||
}
|
||||
return input.exited() && tree.treeVerdict === 'exited'
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
|
||||
|
||||
describe('Claude child process environment', () => {
|
||||
it('strips case-insensitive auth headers through the shared env patch on Windows', () => {
|
||||
expect(
|
||||
applyClaudeEnvPatch(
|
||||
{
|
||||
anthropic_api_key: 'inherited-key',
|
||||
Anthropic_Custom_Headers: 'Authorization: inherited',
|
||||
SAFE_VALUE: 'preserved'
|
||||
},
|
||||
{},
|
||||
{ stripAuthEnv: true, platform: 'win32' }
|
||||
)
|
||||
).toEqual({ SAFE_VALUE: 'preserved' })
|
||||
})
|
||||
|
||||
it('strips case-insensitive inherited auth and session stamps on Windows', () => {
|
||||
const env = buildClaudeChildProcessEnv(
|
||||
{
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
Claude_Code_Session_Id: 'configured-session'
|
||||
},
|
||||
{
|
||||
platform: 'win32',
|
||||
inheritedEnv: {
|
||||
anthropic_api_key: 'inherited-key',
|
||||
Anthropic_Custom_Headers: 'Authorization: inherited',
|
||||
claude_code_child_session: '1',
|
||||
CLAUDE_CODE_SESSION_ID: 'inherited-session',
|
||||
SAFE_VALUE: 'preserved'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(env).toEqual({
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
Claude_Code_Session_Id: 'configured-session',
|
||||
SAFE_VALUE: 'preserved'
|
||||
})
|
||||
})
|
||||
|
||||
it('can strip child-session stamps reintroduced by a full SDK launch overlay', () => {
|
||||
expect(
|
||||
buildClaudeChildProcessEnv(
|
||||
{
|
||||
CLAUDE_CODE_CHILD_SESSION: 'configured-child-session',
|
||||
CLAUDE_CODE_SESSION_ID: 'configured-session',
|
||||
CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session'
|
||||
},
|
||||
{
|
||||
scrubConfiguredChildSessionStamps: true,
|
||||
inheritedEnv: {
|
||||
CLAUDE_CODE_CHILD_SESSION: 'inherited-child-session',
|
||||
SAFE_VALUE: 'preserved'
|
||||
}
|
||||
}
|
||||
)
|
||||
).toEqual({ SAFE_VALUE: 'preserved' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CLAUDE_AUTH_ENV_VARS, applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
|
||||
const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [
|
||||
'CLAUDE_CODE_CHILD_SESSION',
|
||||
'CLAUDE_CODE_SESSION_ID',
|
||||
'CLAUDE_CODE_BRIDGE_SESSION_ID'
|
||||
] as const
|
||||
|
||||
function cloneProcessEnv(source: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value !== undefined) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function stripClaudeChildSessionStamps(
|
||||
env: Record<string, string>,
|
||||
platform: NodeJS.Platform
|
||||
): Record<string, string> {
|
||||
for (const key of CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS) {
|
||||
for (const envKey of Object.keys(env)) {
|
||||
if (envKey === key || (platform === 'win32' && envKey.toUpperCase() === key)) {
|
||||
delete env[envKey]
|
||||
}
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
export function buildClaudeChildProcessEnv(
|
||||
configuredEnv: Record<string, string> = {},
|
||||
options: {
|
||||
inheritedEnv?: NodeJS.ProcessEnv
|
||||
platform?: NodeJS.Platform
|
||||
scrubConfiguredChildSessionStamps?: boolean
|
||||
} = {}
|
||||
): Record<string, string> {
|
||||
const inheritedEnv = options.inheritedEnv ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
const env = applyClaudeEnvPatch(
|
||||
cloneProcessEnv(inheritedEnv),
|
||||
{},
|
||||
{
|
||||
stripAuthEnv: true,
|
||||
platform
|
||||
}
|
||||
)
|
||||
if (platform === 'win32') {
|
||||
const authKeys = new Set(CLAUDE_AUTH_ENV_VARS.map((key) => key.toUpperCase()))
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const normalized = key.toUpperCase()
|
||||
if (
|
||||
authKeys.has(normalized) ||
|
||||
(normalized === 'ANTHROPIC_CUSTOM_HEADERS' &&
|
||||
/authorization|x-api-key|api-key|bearer/i.test(value))
|
||||
) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.scrubConfiguredChildSessionStamps) {
|
||||
return stripClaudeChildSessionStamps({ ...env, ...configuredEnv }, platform)
|
||||
}
|
||||
stripClaudeChildSessionStamps(env, platform)
|
||||
return { ...env, ...configuredEnv }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SpawnedProcess } from '../../shared/child-process/run-process'
|
||||
import type { PosixProcessIdentity } from '../pty-descendant-termination'
|
||||
import type {
|
||||
WindowsDescendantSnapshot,
|
||||
WindowsProcessIdentity
|
||||
} from '../windows-descendant-exit-verification'
|
||||
|
||||
export type ClaudeRootIdentity = PosixProcessIdentity | WindowsProcessIdentity
|
||||
|
||||
type RootTerminationInput = {
|
||||
child: Pick<SpawnedProcess, 'kill'>
|
||||
exited: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills the root through the handle Node owns rather than through its pid, which
|
||||
* is why no identity probe gates it: libuv drops that handle in the same turn it
|
||||
* reaps, so the signal either reaches the process Orca spawned or reaches
|
||||
* nothing. A probe here could only let an unreadable process table cost the tree
|
||||
* the one fallback that still works once every table read has failed.
|
||||
*
|
||||
* False means no signal was sent, because the root had already left.
|
||||
*/
|
||||
export function terminateClaudeRoot(input: RootTerminationInput): boolean {
|
||||
return input.exited() ? false : input.child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
type WindowsRootTerminationInput = {
|
||||
snapshot: WindowsDescendantSnapshot | null
|
||||
exited: () => boolean
|
||||
verifyRoot: (root: WindowsProcessIdentity) => Promise<boolean>
|
||||
terminateTree: (root: WindowsProcessIdentity) => Promise<void>
|
||||
killRoot: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* `taskkill /T /F` addresses a bare pid, so a dead root's pid may already belong
|
||||
* to a stranger whose whole tree it would take down: that one is identity-gated.
|
||||
* The direct root kill after it runs however the probe decided.
|
||||
*/
|
||||
export async function terminateClaudeWindowsRoot(
|
||||
input: WindowsRootTerminationInput
|
||||
): Promise<{ rootVerified: boolean }> {
|
||||
const { snapshot, exited, verifyRoot, terminateTree, killRoot } = input
|
||||
let rootVerified = false
|
||||
if (!exited() && snapshot) {
|
||||
rootVerified = await verifyRoot(snapshot.root).catch(() => false)
|
||||
if (rootVerified && !exited()) {
|
||||
await terminateTree(snapshot.root).catch(() => {})
|
||||
}
|
||||
}
|
||||
killRoot()
|
||||
return { rootVerified }
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { DescendantSnapshot } from '../pty-descendant-termination'
|
||||
import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification'
|
||||
|
||||
/** One platform's descendant tree, tagged so neither verifier can be handed the other's rows. */
|
||||
export type ClaudeCapturedTree =
|
||||
| { platform: 'posix'; tree: DescendantSnapshot }
|
||||
| { platform: 'win32'; tree: WindowsDescendantSnapshot }
|
||||
|
||||
/**
|
||||
* Process-table reads are not atomic: a refresh can omit a still-live row, but
|
||||
* it can also observe a new process after the old row exited. Retain rows absent
|
||||
* from the refresh, but reject a PID whose identity changed between reads.
|
||||
*/
|
||||
function mergeRowsByPid<Row extends { pid: number }>(
|
||||
previous: readonly Row[],
|
||||
next: readonly Row[],
|
||||
sameIdentity: (previous: Row, next: Row) => boolean,
|
||||
previousBoundary: (row: Row) => number,
|
||||
nextBoundary: (row: Row) => number,
|
||||
refreshBoundary: number
|
||||
): { rows: Row[]; capturedAtMsByPid?: Readonly<Record<string, number>> } | null {
|
||||
const merged = new Map<number, Row>()
|
||||
const capturedAtMsByPid: Record<string, number> = {}
|
||||
for (const row of previous) {
|
||||
const prior = merged.get(row.pid)
|
||||
if (prior && !sameIdentity(prior, row)) {
|
||||
return null
|
||||
}
|
||||
merged.set(row.pid, row)
|
||||
capturedAtMsByPid[String(row.pid)] = previousBoundary(row)
|
||||
}
|
||||
for (const row of next) {
|
||||
const prior = merged.get(row.pid)
|
||||
if (prior && !sameIdentity(prior, row)) {
|
||||
return null
|
||||
}
|
||||
if (!prior) {
|
||||
capturedAtMsByPid[String(row.pid)] = nextBoundary(row)
|
||||
}
|
||||
merged.set(row.pid, row)
|
||||
}
|
||||
const boundaries = Object.values(capturedAtMsByPid)
|
||||
const needsBoundaryMap =
|
||||
new Set(boundaries).size > 1 || boundaries.some((boundary) => boundary !== refreshBoundary)
|
||||
return {
|
||||
rows: [...merged.values()],
|
||||
...(needsBoundaryMap ? { capturedAtMsByPid } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeClaudeCapturedTrees(
|
||||
previous: ClaudeCapturedTree,
|
||||
next: ClaudeCapturedTree
|
||||
): ClaudeCapturedTree | null {
|
||||
if (previous.platform !== next.platform) {
|
||||
return null
|
||||
}
|
||||
if (previous.platform === 'posix' && next.platform === 'posix') {
|
||||
if (previous.tree.rootPgid !== next.tree.rootPgid) {
|
||||
return null
|
||||
}
|
||||
// A refresh cannot repair an earlier capture that lacked root identity;
|
||||
// retaining those rows would permit a later numeric-pid kill without proof.
|
||||
if (!previous.tree.root || !next.tree.root) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
previous.tree.root.pid !== next.tree.root.pid ||
|
||||
previous.tree.root.startedAt !== next.tree.root.startedAt
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const descendants = mergeRowsByPid(
|
||||
previous.tree.descendants,
|
||||
next.tree.descendants,
|
||||
(left, right) => left.pgid === right.pgid && left.startedAt === right.startedAt,
|
||||
(row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs,
|
||||
(row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs,
|
||||
next.tree.capturedAtMs
|
||||
)
|
||||
if (!descendants) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
platform: 'posix',
|
||||
tree: {
|
||||
...next.tree,
|
||||
// Retained rows keep their earlier boundary; new rows use the refresh
|
||||
// boundary. The scalar remains the latest scan for legacy consumers.
|
||||
descendants: descendants.rows,
|
||||
...(descendants.capturedAtMsByPid
|
||||
? { capturedAtMsByPid: descendants.capturedAtMsByPid }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.platform === 'win32' && next.platform === 'win32') {
|
||||
if (
|
||||
previous.tree.root.pid !== next.tree.root.pid ||
|
||||
previous.tree.root.creationTimeMs !== next.tree.root.creationTimeMs
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const descendants = mergeRowsByPid(
|
||||
previous.tree.descendants,
|
||||
next.tree.descendants,
|
||||
(left, right) => left.creationTimeMs === right.creationTimeMs,
|
||||
(row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs,
|
||||
(row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs,
|
||||
next.tree.capturedAtMs
|
||||
)
|
||||
if (!descendants) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
platform: 'win32',
|
||||
tree: {
|
||||
...next.tree,
|
||||
descendants: descendants.rows,
|
||||
...(descendants.capturedAtMsByPid
|
||||
? { capturedAtMsByPid: descendants.capturedAtMsByPid }
|
||||
: {}),
|
||||
unidentifiedCount: Math.max(previous.tree.unidentifiedCount, next.tree.unidentifiedCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
|
||||
function sinkState() {
|
||||
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body) => items.push({ identity, body }),
|
||||
appendTombstone: () => {},
|
||||
publish: vi.fn()
|
||||
}
|
||||
return { sink, items }
|
||||
}
|
||||
|
||||
function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] {
|
||||
return items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The queue-bookkeeping frame Claude Code 2.1.258 emits for every uuid-stamped
|
||||
* command: `command_uuid` plus a state, and no content of its own. Shape and
|
||||
* states taken from the CLI's own emission sites.
|
||||
*/
|
||||
function commandLifecycle(state: 'started' | 'completed' | 'cancelled', uuid: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'command_lifecycle',
|
||||
command_uuid: 'command-1',
|
||||
state,
|
||||
uuid,
|
||||
session_id: 'claude-session'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function userTurn(uuid: string, text: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
startsTurn: true as const,
|
||||
message: {
|
||||
type: 'user',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
isReplay: true,
|
||||
message: { role: 'user', content: text }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assistantReply(uuid: string, text: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude command_lifecycle frames', () => {
|
||||
it('keeps queue bookkeeping off the transcript for a whole turn', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1', 'Reply with exactly PROBE_OK and nothing else.'))
|
||||
translator.handle(commandLifecycle('started', 'lifecycle-1'))
|
||||
translator.handle(assistantReply('assistant-1', 'PROBE_OK'))
|
||||
translator.handle(commandLifecycle('completed', 'lifecycle-2'))
|
||||
translator.handle(commandLifecycle('completed', 'lifecycle-3'))
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
uuid: 'result-1',
|
||||
session_id: 'claude-session',
|
||||
is_error: false,
|
||||
result: 'PROBE_OK',
|
||||
terminal_reason: 'completed'
|
||||
}
|
||||
})
|
||||
|
||||
expect(providerFrameKinds(state.items)).toEqual([])
|
||||
// The turn's real content is untouched.
|
||||
expect(
|
||||
state.items.flatMap((item) =>
|
||||
item.body.kind === 'message' && item.body.role === 'assistant' ? [item.body.blocks] : []
|
||||
)
|
||||
).toEqual([[{ type: 'text', text: 'PROBE_OK' }]])
|
||||
})
|
||||
|
||||
it('keeps a cancelled command off the transcript too', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(commandLifecycle('cancelled', 'lifecycle-4'))
|
||||
|
||||
expect(providerFrameKinds(state.items)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { claudeConfigDirEnvPatch, defaultClaudeConfigDir } from './claude-config-dir-pin'
|
||||
|
||||
describe('claude config dir pin', () => {
|
||||
it('does not pin the CLI default home, so the macOS Keychain stays reachable', () => {
|
||||
expect(claudeConfigDirEnvPatch(join(homedir(), '.claude'), { env: {} })).toEqual({})
|
||||
expect(claudeConfigDirEnvPatch(`${join(homedir(), '.claude')}/`, { env: {} })).toEqual({})
|
||||
expect(claudeConfigDirEnvPatch(' ', { env: {} })).toEqual({})
|
||||
})
|
||||
|
||||
it('pins a managed account home the CLI would not find on its own', () => {
|
||||
expect(claudeConfigDirEnvPatch('/accounts/claude/managed', { env: {} })).toEqual({
|
||||
CLAUDE_CONFIG_DIR: '/accounts/claude/managed'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an inherited CLAUDE_CONFIG_DIR as the default the CLI already resolves', () => {
|
||||
const env = { CLAUDE_CONFIG_DIR: '/inherited/home' }
|
||||
expect(defaultClaudeConfigDir(env)).toBe('/inherited/home')
|
||||
expect(claudeConfigDirEnvPatch('/inherited/home', { env })).toEqual({})
|
||||
expect(claudeConfigDirEnvPatch('/other/home', { env })).toEqual({
|
||||
CLAUDE_CONFIG_DIR: '/other/home'
|
||||
})
|
||||
})
|
||||
|
||||
it('compares Windows homes case-insensitively', () => {
|
||||
const env = { CLAUDE_CONFIG_DIR: 'C:\\Users\\Work\\.claude' }
|
||||
expect(claudeConfigDirEnvPatch('c:\\users\\work\\.claude', { env, platform: 'win32' })).toEqual(
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
/** The config dir the Claude CLI resolves for itself when nothing pins one. */
|
||||
export function defaultClaudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
|
||||
}
|
||||
|
||||
function samePath(a: string, b: string, platform: NodeJS.Platform): boolean {
|
||||
const left = resolve(a)
|
||||
const right = resolve(b)
|
||||
return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
||||
}
|
||||
|
||||
/**
|
||||
* An explicit CLAUDE_CONFIG_DIR moves the Claude CLI off the default Keychain item onto
|
||||
* one derived from the pinned path, so a claude.ai OAuth login stops working even when
|
||||
* the pin names the CLI's own default. Pin only a home the CLI would not find on its
|
||||
* own — the same rule the legacy PTY path applies via `ClaudeRuntimePathResolver`.
|
||||
*
|
||||
* The pinned value is the account home verbatim: the CLI keys its credential lookup on
|
||||
* the literal string, so re-spelling an equivalent path (absolute vs `~`, trailing
|
||||
* separator) selects a different identity. Normalization here is for the equality test
|
||||
* only and must never reach the env.
|
||||
*/
|
||||
export function claudeConfigDirEnvPatch(
|
||||
accountHome: string,
|
||||
options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
|
||||
): { CLAUDE_CONFIG_DIR?: string } {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
const resolved = accountHome.trim()
|
||||
if (!resolved || samePath(resolved, defaultClaudeConfigDir(env), platform)) {
|
||||
return {}
|
||||
}
|
||||
return { CLAUDE_CONFIG_DIR: resolved }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { terminateDescendantSnapshotWithVerdict } from '../pty-descendant-exit-verification'
|
||||
import {
|
||||
collectDescendantRows,
|
||||
type DescendantSnapshot,
|
||||
type ProcessTableRow
|
||||
} from '../pty-descendant-termination'
|
||||
import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof'
|
||||
|
||||
const ROOT_PID = 500
|
||||
const ORCA_PGID = 400
|
||||
const ROOT_STARTED_AT = 'Thu Sep 3 18:04:50 2026'
|
||||
/** The second both close-time walks land in. */
|
||||
const WALK_SECOND = 'Thu Sep 3 18:05:04 2026'
|
||||
const WALK_MS = Date.parse(WALK_SECOND)
|
||||
const EARLIER_SECOND = 'Thu Sep 3 18:05:03 2026'
|
||||
|
||||
/** The measured split: `s20` at :03.946 died, `s21` at :04.042 leaked. */
|
||||
const EARLIER_BORN = [700, 701, 702]
|
||||
const WALK_SECOND_BORN = [721, 722, 723, 724]
|
||||
|
||||
type Cohort = { pids: number[]; startedAt: string }
|
||||
|
||||
const LIVE_TREE: Cohort[] = [
|
||||
{ pids: EARLIER_BORN, startedAt: EARLIER_SECOND },
|
||||
{ pids: WALK_SECOND_BORN, startedAt: WALK_SECOND }
|
||||
]
|
||||
|
||||
function rowsFor(cohorts: Cohort[]): ProcessTableRow[] {
|
||||
return [
|
||||
{ pid: ROOT_PID, ppid: 1, pgid: ORCA_PGID, startedAt: ROOT_STARTED_AT },
|
||||
...cohorts.flatMap((cohort) =>
|
||||
cohort.pids.map((pid) => ({
|
||||
pid,
|
||||
ppid: ROOT_PID,
|
||||
pgid: ORCA_PGID,
|
||||
startedAt: cohort.startedAt
|
||||
}))
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
/** A real ppid walk from the root, exactly as production captures one. */
|
||||
function walk(capturedAtMs: number, cohorts: Cohort[] = LIVE_TREE): DescendantSnapshot {
|
||||
return collectDescendantRows(ROOT_PID, rowsFor(cohorts), capturedAtMs)
|
||||
}
|
||||
|
||||
function killedPids(calls: [number, NodeJS.Signals][]): number[] {
|
||||
return calls.flatMap(([pid, signal]) => (signal === 'SIGKILL' ? [pid] : [])).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function signalledPids(calls: [number, NodeJS.Signals][]): number[] {
|
||||
return calls.flatMap(([pid, signal]) => (signal === 'SIGTERM' ? [pid] : [])).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the real reaper and the real verifier against a process table where
|
||||
* every descendant traps SIGTERM, so only a forced sweep can end them. The root
|
||||
* is alive for both walks and gone by the sweep, which is the measured teardown.
|
||||
*/
|
||||
async function sweep(
|
||||
captures: DescendantSnapshot[],
|
||||
liveTree: Cohort[] = LIVE_TREE
|
||||
): Promise<[number, NodeJS.Signals][]> {
|
||||
const calls: [number, NodeJS.Signals][] = []
|
||||
const captureDescendants = vi.fn()
|
||||
for (const capture of captures) {
|
||||
captureDescendants.mockResolvedValueOnce(capture)
|
||||
}
|
||||
const tree = createClaudeChildTreeReaper(
|
||||
{ pid: ROOT_PID, kill: vi.fn(() => true) },
|
||||
{
|
||||
platform: 'linux',
|
||||
exited: () => false,
|
||||
captureDescendants,
|
||||
terminateDescendants: (snapshot) =>
|
||||
terminateDescendantSnapshotWithVerdict(snapshot, {
|
||||
requireIdentityBeforeSignal: true,
|
||||
graceMs: 0,
|
||||
verifyMs: 120,
|
||||
sendSignal: (pid, signal) => calls.push([pid, signal]),
|
||||
readTable: async () => ({ rows: rowsFor(liveTree), capturedAtMs: Date.now() })
|
||||
})
|
||||
}
|
||||
)
|
||||
// The close ladder's shape: arm, then re-walk the live root at the boundary.
|
||||
await tree.capture()
|
||||
await tree.refresh?.()
|
||||
await tree.reap()
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('Claude descendant forced-sweep fence', () => {
|
||||
it('escalates a descendant forked in the same second as both close walks', async () => {
|
||||
// Both walks land inside second :04, one ps duration apart, and the root is
|
||||
// gone before a third could run. A descendant born at :04.042 is no less
|
||||
// ours than its sibling born 96ms earlier at :03.946.
|
||||
const calls = await sweep([walk(WALK_MS + 42), walk(WALK_MS + 140)])
|
||||
|
||||
expect(signalledPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN])
|
||||
expect(killedPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN])
|
||||
})
|
||||
|
||||
it('still escalates descendants born before the walk that first saw them', async () => {
|
||||
const onlyEarlier = [{ pids: EARLIER_BORN, startedAt: EARLIER_SECOND }]
|
||||
const calls = await sweep([walk(WALK_MS + 42, onlyEarlier)], onlyEarlier)
|
||||
|
||||
expect(killedPids(calls)).toEqual(EARLIER_BORN)
|
||||
})
|
||||
|
||||
it('withholds the sweep from a row no walk re-derived, on its start second alone', async () => {
|
||||
// 900 was seen once, in its own birth second, and the refresh did not find
|
||||
// it. The merge retains the row, but nothing re-proved it belongs to us, so
|
||||
// the second-resolution fence is all there is and it still says no.
|
||||
const retained = { pids: [900], startedAt: WALK_SECOND }
|
||||
const firstWalk = walk(WALK_MS + 42, [...LIVE_TREE, retained])
|
||||
const refresh = walk(WALK_MS + 140)
|
||||
|
||||
const calls = await sweep([firstWalk, refresh], [...LIVE_TREE, retained])
|
||||
|
||||
expect(signalledPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN, 900])
|
||||
expect(killedPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { query } from '@anthropic-ai/claude-agent-sdk'
|
||||
import {
|
||||
openClaudeStreamJsonConnection,
|
||||
type ClaudeStreamJsonLaunch
|
||||
} from './claude-stream-json-connection'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const refresh = vi.fn()
|
||||
const proveClaudeChildExit = vi.fn()
|
||||
const tree = {
|
||||
capture: vi.fn(async () => {}),
|
||||
refresh: (...args: unknown[]) => refresh(...args),
|
||||
reap: vi.fn(async () => 'exited' as const),
|
||||
treeVerdict: 'unverifiable' as const
|
||||
}
|
||||
return { proveClaudeChildExit, refresh, tree }
|
||||
})
|
||||
|
||||
vi.mock('./claude-agent-sdk-exit-proof', () => ({
|
||||
createClaudeChildTreeReaper: vi.fn(() => mocks.tree),
|
||||
proveClaudeChildExit: (...args: unknown[]) => mocks.proveClaudeChildExit(...args)
|
||||
}))
|
||||
|
||||
function fakeChild(): ChildProcessWithoutNullStreams {
|
||||
const child = new EventEmitter()
|
||||
return Object.assign(child, {
|
||||
pid: 424242,
|
||||
stdin: new PassThrough(),
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: vi.fn()
|
||||
}) as unknown as ChildProcessWithoutNullStreams
|
||||
}
|
||||
|
||||
describe('Claude stream-json close ordering', () => {
|
||||
it('waits for the live tree refresh before ending stdin', async () => {
|
||||
const refreshDone = Promise.withResolvers<void>()
|
||||
mocks.refresh.mockReturnValueOnce(refreshDone.promise)
|
||||
mocks.proveClaudeChildExit.mockResolvedValueOnce(true)
|
||||
const child = fakeChild()
|
||||
const launch: ClaudeStreamJsonLaunch = {
|
||||
pathToClaudeCodeExecutable: 'claude',
|
||||
options: {},
|
||||
cwd: '/work/repo'
|
||||
}
|
||||
const queryImpl = ((params: Parameters<typeof query>[0]) => {
|
||||
if (!params.options) {
|
||||
throw new Error('missing SDK options')
|
||||
}
|
||||
params.options.spawnClaudeCodeProcess?.({
|
||||
command: 'claude',
|
||||
args: [],
|
||||
env: {},
|
||||
signal: new AbortController().signal
|
||||
})
|
||||
void (async () => {
|
||||
for await (const _message of params.prompt) {
|
||||
// The SDK owns the transport write; the close test only needs its EOF boundary.
|
||||
}
|
||||
child.stdin.end()
|
||||
})()
|
||||
return (async function* () {})()
|
||||
}) as typeof query
|
||||
const connection = await openClaudeStreamJsonConnection(launch, {}, () => child, queryImpl)
|
||||
|
||||
const closing = connection.close()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(child.stdin.writableEnded).toBe(false)
|
||||
|
||||
refreshDone.resolve()
|
||||
await expect(closing).resolves.toBe(true)
|
||||
expect(child.stdin.writableEnded).toBe(true)
|
||||
})
|
||||
|
||||
it('requests a fresh close boundary after an output capture starts', async () => {
|
||||
mocks.refresh.mockReset()
|
||||
mocks.proveClaudeChildExit.mockReset()
|
||||
const outputCapture = Promise.withResolvers<void>()
|
||||
const closeCapture = Promise.withResolvers<void>()
|
||||
mocks.refresh
|
||||
.mockReturnValueOnce(outputCapture.promise)
|
||||
.mockReturnValueOnce(closeCapture.promise)
|
||||
mocks.proveClaudeChildExit.mockResolvedValueOnce(true)
|
||||
const child = fakeChild()
|
||||
const launch: ClaudeStreamJsonLaunch = {
|
||||
pathToClaudeCodeExecutable: 'claude',
|
||||
options: {},
|
||||
cwd: '/work/repo'
|
||||
}
|
||||
const queryImpl = ((params: Parameters<typeof query>[0]) => {
|
||||
params.options?.spawnClaudeCodeProcess?.({
|
||||
command: 'claude',
|
||||
args: [],
|
||||
env: {},
|
||||
signal: new AbortController().signal
|
||||
})
|
||||
void (async () => {
|
||||
for await (const _message of params.prompt) {
|
||||
// The SDK owns the transport write; the close test only needs its EOF boundary.
|
||||
}
|
||||
child.stdin.end()
|
||||
})()
|
||||
return (async function* () {})()
|
||||
}) as typeof query
|
||||
const connection = await openClaudeStreamJsonConnection(launch, {}, () => child, queryImpl)
|
||||
|
||||
child.stderr.emit('data', 'output')
|
||||
await vi.waitFor(() => expect(mocks.refresh).toHaveBeenCalledTimes(1))
|
||||
const closing = connection.close()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.refresh).toHaveBeenCalledTimes(2)
|
||||
expect(child.stdin.writableEnded).toBe(false)
|
||||
|
||||
outputCapture.resolve()
|
||||
await Promise.resolve()
|
||||
expect(child.stdin.writableEnded).toBe(false)
|
||||
closeCapture.resolve()
|
||||
await expect(closing).resolves.toBe(true)
|
||||
expect(child.stdin.writableEnded).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,768 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process'
|
||||
import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate'
|
||||
import type { ProcessSpec } from '../../shared/child-process/process-spec'
|
||||
import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk'
|
||||
import {
|
||||
openClaudeStreamJsonConnection,
|
||||
type ClaudeStreamJsonConnection,
|
||||
type ClaudeStreamJsonLaunch
|
||||
} from './claude-stream-json-connection'
|
||||
import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory'
|
||||
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { claudeAuthDiagnostic } from './claude-structured-init-proof'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import { readClaudeStructuredSessionOptions } from './claude-structured-session-options'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
|
||||
|
||||
// These drive the real SDK against the scripted fake CLI, so every assertion is
|
||||
// about the environment, argv and frames a real child actually saw.
|
||||
const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs')
|
||||
const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f'
|
||||
const HOLD_OPEN = { delayMs: 10_000 }
|
||||
|
||||
type ScriptedCliReport = {
|
||||
argv: string[]
|
||||
controlRequests: { request_id: string; request: { subtype: string } }[]
|
||||
controlResponses: { response: { request_id: string; response?: unknown } }[]
|
||||
userMessages: Record<string, unknown>[]
|
||||
descendantPid: number | null
|
||||
}
|
||||
|
||||
const scratchDirs: string[] = []
|
||||
const openConnections: ClaudeStreamJsonConnection[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const connection of openConnections.splice(0)) {
|
||||
await connection.close()
|
||||
}
|
||||
for (const dir of scratchDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
spawned.splice(0)
|
||||
spawnedChildren.splice(0)
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
function scriptScenario(
|
||||
steps: Record<string, unknown>[],
|
||||
controlResponses: Record<string, unknown> = {}
|
||||
) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'claude-sdk-connection-'))
|
||||
scratchDirs.push(dir)
|
||||
const scenarioPath = join(dir, 'scenario.json')
|
||||
const reportPath = join(dir, 'report.json')
|
||||
writeFileSync(scenarioPath, JSON.stringify({ steps, controlResponses }))
|
||||
return {
|
||||
cwd: dir,
|
||||
env: {
|
||||
PATH: process.env.PATH ?? '',
|
||||
ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath,
|
||||
ORCA_SDK_CONTRACT_REPORT_PATH: reportPath
|
||||
},
|
||||
readReport: () => JSON.parse(readFileSync(reportPath, 'utf8')) as ScriptedCliReport
|
||||
}
|
||||
}
|
||||
|
||||
function launchFor(
|
||||
scenario: { cwd: string; env: Record<string, string> },
|
||||
env: Record<string, string> = {}
|
||||
): ClaudeStreamJsonLaunch {
|
||||
return {
|
||||
pathToClaudeCodeExecutable: FAKE_CLI,
|
||||
options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: SESSION_ID },
|
||||
cwd: scenario.cwd,
|
||||
env: { ...scenario.env, ...env }
|
||||
}
|
||||
}
|
||||
|
||||
/** The derived child environment, captured where Orca actually hands it to the OS. */
|
||||
const spawned: ProcessSpec[] = []
|
||||
/** The retained child, so a test can end it the way a crashing CLI would. */
|
||||
const spawnedChildren: SpawnedProcess[] = []
|
||||
|
||||
async function open(
|
||||
launch: ClaudeStreamJsonLaunch,
|
||||
handlers: Parameters<typeof openClaudeStreamJsonConnection>[1] = {},
|
||||
queryImpl?: typeof query
|
||||
): Promise<ClaudeStreamJsonConnection> {
|
||||
const connection = await openClaudeStreamJsonConnection(
|
||||
launch,
|
||||
handlers,
|
||||
(spec) => {
|
||||
spawned.push(spec)
|
||||
const child = spawnProcess(spec)
|
||||
spawnedChildren.push(child)
|
||||
return child
|
||||
},
|
||||
queryImpl
|
||||
)
|
||||
openConnections.push(connection)
|
||||
return connection
|
||||
}
|
||||
|
||||
function childEnv(): Record<string, string | undefined> {
|
||||
return (spawned.at(-1)?.env ?? {}) as Record<string, string | undefined>
|
||||
}
|
||||
|
||||
async function until<T>(read: () => T | null | undefined, label: string): Promise<T> {
|
||||
for (let attempt = 0; attempt < 400; attempt++) {
|
||||
const value = read()
|
||||
if (value !== null && value !== undefined) {
|
||||
return value
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`)
|
||||
}
|
||||
|
||||
function readReportSafely(scenario: { readReport: () => ScriptedCliReport }) {
|
||||
try {
|
||||
return scenario.readReport()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function processState(pid: number): 'running' | 'exited' {
|
||||
try {
|
||||
const state = execFileSync('ps', ['-o', 'state=', '-p', String(pid)], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LANG: 'C', LC_ALL: 'C' }
|
||||
}).trim()
|
||||
return state.startsWith('Z') ? 'exited' : 'running'
|
||||
} catch (error) {
|
||||
if ((error as { status?: number }).status === 1) {
|
||||
return 'exited'
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude stream-json connection', () => {
|
||||
it('passes the Claude Code system-prompt preset through to SDK query', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN])
|
||||
let captured: Options | undefined
|
||||
await open(launchFor(scenario), {}, (params) => {
|
||||
captured = params.options
|
||||
return query(params)
|
||||
})
|
||||
|
||||
expect(captured?.systemPrompt).toEqual({ type: 'preset', preset: 'claude_code' })
|
||||
})
|
||||
|
||||
it('hands the child a derived environment, the resolved CLI path, and keeps the pid', async () => {
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK')
|
||||
vi.stubEnv('CLAUDE_CODE_CHILD_SESSION', '1')
|
||||
vi.stubEnv('NODE_OPTIONS', '--require=/tmp/inject.js')
|
||||
// An inherited value wins over the SDK's default, so clear it to pin the default.
|
||||
vi.stubEnv('CLAUDE_CODE_ENTRYPOINT', undefined)
|
||||
vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited')
|
||||
const scenario = scriptScenario([HOLD_OPEN])
|
||||
const connection = await open(
|
||||
launchFor(scenario, {
|
||||
CLAUDE_CONFIG_DIR: '/accounts/managed/home',
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-9',
|
||||
CLAUDE_CODE_CHILD_SESSION: 'configured-child-session',
|
||||
CLAUDE_CODE_SESSION_ID: 'configured-session',
|
||||
CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session'
|
||||
})
|
||||
)
|
||||
|
||||
// Ownership proof: the pid is a real live process, not a value the SDK reported.
|
||||
expect(connection.pid).toEqual(expect.any(Number))
|
||||
expect(() => process.kill(connection.pid as number, 0)).not.toThrow()
|
||||
const env = childEnv()
|
||||
// The managed home is pinned verbatim: the CLI keys credential lookup on the literal string.
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/accounts/managed/home')
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('configured-token')
|
||||
expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-9')
|
||||
expect(env.ORCA_CONNECTION_MARKER).toBe('inherited')
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined()
|
||||
expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined()
|
||||
expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined()
|
||||
// Two SDK mutations of the child env, pinned so a bump cannot change them unseen.
|
||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts')
|
||||
expect(env.NODE_OPTIONS).toBeUndefined()
|
||||
// The bundled binary is excluded from the install, so the resolved path is mandatory.
|
||||
const report = await until(() => readReportSafely(scenario), 'the scripted CLI report')
|
||||
expect(report.argv[0]).toBe(FAKE_CLI)
|
||||
// The .mjs fixture makes the SDK run it under node; a real CLI path is the program
|
||||
// itself. Either way the resolved path is what Orca's spawner is asked to execute.
|
||||
expect([spawned.at(-1)?.program, ...(spawned.at(-1)?.args ?? [])]).toContain(FAKE_CLI)
|
||||
expect(report.argv).toContain('--replay-user-messages')
|
||||
expect(report.argv).toContain(`--session-id=${SESSION_ID}`)
|
||||
})
|
||||
|
||||
it('leaves the default CLI home unpinned so macOS Keychain OAuth keeps working', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN])
|
||||
await open(launchFor(scenario))
|
||||
|
||||
await until(() => readReportSafely(scenario), 'the scripted CLI report')
|
||||
expect(childEnv().CLAUDE_CONFIG_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('settles a send only once the frame reached the child, and replays reach onMessage', async () => {
|
||||
const replay = {
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
parent_tool_use_id: null,
|
||||
isReplay: true,
|
||||
session_id: SESSION_ID,
|
||||
uuid: 'uuid-replay-1'
|
||||
}
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: replay }, HOLD_OPEN])
|
||||
const messages: Record<string, unknown>[] = []
|
||||
const connection = await open(launchFor(scenario), {
|
||||
onMessage: (message) => messages.push(message)
|
||||
})
|
||||
|
||||
await connection.send({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
parent_tool_use_id: null,
|
||||
session_id: SESSION_ID
|
||||
})
|
||||
// The report exists from the child's first line of work, so poll for the frame
|
||||
// itself: `send` settles on the SDK's completed write, and the child still has
|
||||
// to read that line before it can record it.
|
||||
const report = await until(
|
||||
() => (readReportSafely(scenario)?.userMessages.length ? readReportSafely(scenario) : null),
|
||||
'the user frame recorded by the child'
|
||||
)
|
||||
expect(report.userMessages).toHaveLength(1)
|
||||
|
||||
await until(() => messages.find((message) => message.uuid === 'uuid-replay-1'), 'the replay')
|
||||
// The replay is delivered verbatim, so the dispatch acknowledgement still binds on it.
|
||||
expect(messages.find((message) => message.uuid === 'uuid-replay-1')).toEqual(replay)
|
||||
})
|
||||
|
||||
it('rejects a send the SDK pulled but could not write to a terminated child', async () => {
|
||||
const scenario = scriptScenario([{ awaitUserMessage: true }, HOLD_OPEN])
|
||||
const connection = await open(launchFor(scenario))
|
||||
const child = spawnedChildren.at(-1)
|
||||
|
||||
// Same tick as the send, so the liveness guard still passes and the frame
|
||||
// reaches the SDK's input pump: its `transport.write` is what fails, which is
|
||||
// the window a child crashing mid-send actually opens.
|
||||
child?.kill('SIGKILL')
|
||||
const sent = connection.send({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
parent_tool_use_id: null,
|
||||
session_id: SESSION_ID
|
||||
})
|
||||
|
||||
await expect(sent).rejects.toThrow()
|
||||
expect(readReportSafely(scenario)?.userMessages ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('delivers an unmodeled frame verbatim so the provider-fallback row survives', async () => {
|
||||
const unknown = {
|
||||
type: 'frame_kind_from_the_future',
|
||||
session_id: SESSION_ID,
|
||||
uuid: 'uuid-unknown-1',
|
||||
payload: { nested: { flags: ['a', 'b'] } }
|
||||
}
|
||||
const scenario = scriptScenario([{ emit: unknown }, HOLD_OPEN])
|
||||
const messages: Record<string, unknown>[] = []
|
||||
await open(launchFor(scenario), { onMessage: (message) => messages.push(message) })
|
||||
|
||||
await until(() => messages.find((message) => message.uuid === 'uuid-unknown-1'), 'the frame')
|
||||
expect(messages.find((message) => message.uuid === 'uuid-unknown-1')).toEqual(unknown)
|
||||
})
|
||||
|
||||
it('commits the real partial-message cadence as one assistant item through the translator', async () => {
|
||||
// The frame order and per-frame uuids are the ones Claude Code 2.1.258 emits
|
||||
// under --include-partial-messages: every stream_event and the block's final
|
||||
// assistant frame each carry their own uuid; only message.id ties them.
|
||||
const stream = (uuid: string, event: Record<string, unknown>) => ({
|
||||
type: 'stream_event',
|
||||
uuid,
|
||||
session_id: SESSION_ID,
|
||||
parent_tool_use_id: null,
|
||||
event
|
||||
})
|
||||
const frames = [
|
||||
stream('uuid-message-start', {
|
||||
type: 'message_start',
|
||||
message: { id: 'msg_01', role: 'assistant', content: [] }
|
||||
}),
|
||||
stream('uuid-block-start', {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' }
|
||||
}),
|
||||
stream('uuid-delta-1', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'ST' }
|
||||
}),
|
||||
stream('uuid-delta-2', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'REAMOK_ELEC_64E632' }
|
||||
}),
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'uuid-assistant-final',
|
||||
session_id: SESSION_ID,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
id: 'msg_01',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'STREAMOK_ELEC_64E632' }],
|
||||
stop_reason: null
|
||||
}
|
||||
},
|
||||
stream('uuid-block-stop', { type: 'content_block_stop', index: 0 }),
|
||||
stream('uuid-message-delta', { type: 'message_delta', delta: { stop_reason: 'end_turn' } }),
|
||||
stream('uuid-message-stop', { type: 'message_stop' }),
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 1,
|
||||
num_turns: 1,
|
||||
result: 'STREAMOK_ELEC_64E632',
|
||||
stop_reason: 'end_turn',
|
||||
session_id: SESSION_ID,
|
||||
uuid: 'uuid-result'
|
||||
}
|
||||
]
|
||||
const scenario = scriptScenario([...frames.map((frame) => ({ emit: frame })), HOLD_OPEN])
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: SESSION_ID, leafUuid: 'leaf-1' }
|
||||
},
|
||||
journalDir: join(scenario.cwd, 'journal'),
|
||||
now: () => 1_700_000_000_000,
|
||||
mintEpoch: () => 'epoch-1'
|
||||
})
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink()
|
||||
deferred.bind({ journal, fence: 1, publish: vi.fn() })
|
||||
const translator = createClaudeJournalTranslator({ sink: deferred.sink })
|
||||
let settled = false
|
||||
await open(launchFor(scenario), {
|
||||
onMessage: (message) => {
|
||||
translator.handle({ type: 'message', sessionId: 'session-1', message })
|
||||
settled ||= message.type === 'result'
|
||||
}
|
||||
})
|
||||
|
||||
await until(() => (settled ? true : null), 'the result frame')
|
||||
await deferred.drained()
|
||||
const items = journal.snapshot().items
|
||||
const assistant = items.filter(
|
||||
(item) => item.body.kind === 'message' && item.body.role === 'assistant'
|
||||
)
|
||||
expect(assistant.map((item) => item.body)).toEqual([
|
||||
{
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'STREAMOK_ELEC_64E632' }]
|
||||
}
|
||||
])
|
||||
expect(assistant.map((item) => item.itemId)).toEqual([`claude:${SESSION_ID}:uuid-block-start`])
|
||||
expect(
|
||||
items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : []
|
||||
)
|
||||
).toEqual([])
|
||||
// The journal owns a SQLite connection now; afterEach removes this temp root and an open
|
||||
// handle blocks that on Windows.
|
||||
await journal.close()
|
||||
})
|
||||
|
||||
it('feeds an inbound permission request to canUseTool and writes its answer back on the same id', async () => {
|
||||
const scenario = scriptScenario([
|
||||
{
|
||||
emit: {
|
||||
type: 'control_request',
|
||||
request_id: 'perm-421',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
tool_use_id: 'toolu_1',
|
||||
permission_suggestions: [{ type: 'addRules' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ awaitControlResponse: 'perm-421' },
|
||||
HOLD_OPEN
|
||||
])
|
||||
const seen: { toolName: string; requestId: string; toolUseID: string; suggestions: unknown }[] =
|
||||
[]
|
||||
const canUseTool: CanUseTool = (toolName, _input, options) => {
|
||||
seen.push({
|
||||
toolName,
|
||||
requestId: options.requestId,
|
||||
toolUseID: options.toolUseID,
|
||||
suggestions: options.suggestions
|
||||
})
|
||||
return Promise.resolve({ behavior: 'deny', message: 'No', toolUseID: options.toolUseID })
|
||||
}
|
||||
await open(launchFor(scenario), { canUseTool })
|
||||
|
||||
await until(() => (seen.length > 0 ? seen : null), 'the inbound permission request')
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
toolName: 'Bash',
|
||||
requestId: 'perm-421',
|
||||
toolUseID: 'toolu_1',
|
||||
suggestions: [{ type: 'addRules' }]
|
||||
}
|
||||
])
|
||||
const written = await until(
|
||||
() =>
|
||||
readReportSafely(scenario)?.controlResponses.find(
|
||||
(frame) => frame.response.request_id === 'perm-421'
|
||||
),
|
||||
'the permission answer'
|
||||
)
|
||||
expect(written.response.response).toMatchObject({ behavior: 'deny', message: 'No' })
|
||||
})
|
||||
|
||||
it('drives Orca control methods onto the SDK and times out with the init proof message', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN], {
|
||||
initialize: { models: [{ value: 'sonnet' }], account: { tokenSource: 'oauth' } },
|
||||
get_settings: { env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } }
|
||||
})
|
||||
const connection = await open(launchFor(scenario))
|
||||
|
||||
await expect(connection.initializationResult()).resolves.toMatchObject({
|
||||
models: [{ value: 'sonnet' }]
|
||||
})
|
||||
await expect(connection.getSettings()).resolves.toEqual({
|
||||
env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' }
|
||||
})
|
||||
await expect(connection.setModel('opus')).resolves.toBeUndefined()
|
||||
const requests = await until(
|
||||
() =>
|
||||
readReportSafely(scenario)?.controlRequests.find(
|
||||
(frame) => frame.request.subtype === 'set_model'
|
||||
),
|
||||
'the set_model control request'
|
||||
)
|
||||
expect(requests.request.subtype).toBe('set_model')
|
||||
})
|
||||
|
||||
it('reads supportedModels from the catalog the running CLI reported', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN], {
|
||||
initialize: {
|
||||
models: [
|
||||
{ value: 'default', resolvedModel: 'claude-opus-5' },
|
||||
{
|
||||
value: 'opus',
|
||||
displayName: 'Opus 5',
|
||||
description: 'The live row, not the seed',
|
||||
resolvedModel: 'claude-opus-5',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'high']
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const connection = await open(launchFor(scenario))
|
||||
|
||||
await expect(connection.supportedModels()).resolves.toMatchObject([
|
||||
{ value: 'default', resolvedModel: 'claude-opus-5' },
|
||||
{ value: 'opus', displayName: 'Opus 5', supportedEffortLevels: ['low', 'high'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('serves the picker the live catalog rather than falling back to the static seed', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN], {
|
||||
initialize: {
|
||||
models: [
|
||||
{ value: 'default', resolvedModel: 'claude-opus-5' },
|
||||
{
|
||||
value: 'opus',
|
||||
displayName: 'Opus 5',
|
||||
description: 'The live row, not the seed',
|
||||
resolvedModel: 'claude-opus-5',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'high']
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const connection = await open(launchFor(scenario))
|
||||
const session = {
|
||||
connection,
|
||||
options: new Map<string, string>(),
|
||||
reportedOptions: {}
|
||||
} as unknown as ClaudeSession
|
||||
|
||||
const options = await readClaudeStructuredSessionOptions(session, 5_000)
|
||||
|
||||
// The seed carries neither this description nor a two-level effort list, so
|
||||
// both can only have come from the child.
|
||||
expect(options.models).toContainEqual({
|
||||
id: 'opus',
|
||||
label: 'Opus 5',
|
||||
description: 'The live row, not the seed',
|
||||
isDefault: true,
|
||||
efforts: [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'high', label: 'High' }
|
||||
]
|
||||
})
|
||||
expect(options.current.model).toBe('opus')
|
||||
})
|
||||
|
||||
it('feeds the auth diagnostic from the settings the running child reports', async () => {
|
||||
for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) {
|
||||
vi.stubEnv(key, undefined)
|
||||
}
|
||||
const scenario = scriptScenario([HOLD_OPEN], {
|
||||
get_settings: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://settings.example.test',
|
||||
ANTHROPIC_AUTH_TOKEN: 'secret'
|
||||
}
|
||||
}
|
||||
})
|
||||
const connection = await open(launchFor(scenario))
|
||||
const init = { providerSessionId: SESSION_ID, uuid: null, model: null, message: {} }
|
||||
|
||||
// With no ambient auth, every true below can only have come from the CLI's settings.
|
||||
expect(claudeAuthDiagnostic(init, null)).toMatchObject({
|
||||
baseUrlConfigured: false,
|
||||
authTokenConfigured: false
|
||||
})
|
||||
const diagnostic = claudeAuthDiagnostic(init, await connection.getSettings())
|
||||
expect(diagnostic).toMatchObject({
|
||||
baseUrlConfigured: true,
|
||||
authTokenConfigured: true,
|
||||
apiKeyConfigured: false
|
||||
})
|
||||
expect(JSON.stringify(diagnostic)).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('reports an unauthenticated start through the init deadline instead of hanging', async () => {
|
||||
// The scripted CLI never answers, which is the shape of a silently unauthenticated CLI.
|
||||
const scenario = scriptScenario([HOLD_OPEN])
|
||||
const connection = await open({
|
||||
...launchFor(scenario),
|
||||
env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS: '1' }
|
||||
})
|
||||
|
||||
await expect(connection.initializationResult({ timeoutMs: 200 })).rejects.toThrow(
|
||||
'claude initialize request timed out'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a self-exit with its status and stderr, and leaves its tree unverifiable', async () => {
|
||||
const scenario = scriptScenario([{ stderr: 'claude: not signed in\n' }, { exit: 1 }])
|
||||
let exit: Error | null = null
|
||||
const connection = await open(launchFor(scenario), {
|
||||
onExit: (error) => {
|
||||
exit = error
|
||||
}
|
||||
})
|
||||
|
||||
await until(() => exit, 'the exit error')
|
||||
// The status and stderr are the only diagnostic a refused start leaves behind.
|
||||
expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/)
|
||||
expect(connection.closed).toBe(true)
|
||||
// The root's exit is first-hand, but it left before a descendant snapshot
|
||||
// could be armed, so close() has no tree proof to offer and says so.
|
||||
await expect(connection.close()).resolves.toBe(false)
|
||||
expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'unverifiable' })
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'proves a natural SDK exit and cleans up its descendant before recovery',
|
||||
async () => {
|
||||
const scenario = scriptScenario([
|
||||
{ stderr: 'claude: natural exit\n' },
|
||||
{ delayMs: 500 },
|
||||
{ exit: 1 }
|
||||
])
|
||||
let exit: Error | null = null
|
||||
const connection = await open(
|
||||
{
|
||||
...launchFor(scenario),
|
||||
env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_DESCENDANT: '1' }
|
||||
},
|
||||
{ onExit: (error) => (exit = error) }
|
||||
)
|
||||
const report = await until(() => {
|
||||
const current = readReportSafely(scenario)
|
||||
return current?.descendantPid ? current : null
|
||||
}, 'the descendant report')
|
||||
await until(() => exit, 'the natural exit error')
|
||||
try {
|
||||
await expect(connection.close()).resolves.toBe(true)
|
||||
expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'exited' })
|
||||
expect(processState(report.descendantPid as number)).toBe('exited')
|
||||
} finally {
|
||||
try {
|
||||
process.kill(report.descendantPid as number, 'SIGKILL')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
},
|
||||
20_000
|
||||
)
|
||||
|
||||
it('settles a spawn error followed by close as processless and closes idempotently', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN])
|
||||
const missingCli = join(scenario.cwd, 'claude-that-does-not-exist')
|
||||
let fault: Error | null = null
|
||||
let exit: Error | null = null
|
||||
const connection = await open(
|
||||
{ ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli },
|
||||
{
|
||||
onFault: (error) => {
|
||||
fault = error
|
||||
},
|
||||
onExit: (error) => {
|
||||
exit = error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await until(
|
||||
() => (connection.exitVerdict.root === 'processless' ? connection.exitVerdict : null),
|
||||
'the processless spawn settlement'
|
||||
)
|
||||
expect(connection.pid).toBeUndefined()
|
||||
expect(fault).toBeInstanceOf(Error)
|
||||
expect(exit).toBeNull()
|
||||
await expect(Promise.all([connection.close(), connection.close()])).resolves.toEqual([
|
||||
true,
|
||||
true
|
||||
])
|
||||
await expect(connection.close()).resolves.toBe(true)
|
||||
expect(connection.exitVerdict).toEqual({ root: 'processless', tree: 'exited' })
|
||||
})
|
||||
|
||||
it('does not treat a child error event as first-hand root exit proof', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN])
|
||||
let exit: Error | null = null
|
||||
const connection = await open(launchFor(scenario), {
|
||||
onExit: (error) => {
|
||||
exit = error
|
||||
}
|
||||
})
|
||||
const child = spawnedChildren.at(-1)
|
||||
expect(child).toBeDefined()
|
||||
|
||||
child?.emit('error', new Error('child transport fault'))
|
||||
|
||||
expect(exit).toBeNull()
|
||||
expect(connection.exitVerdict.root).toBe('live')
|
||||
await until(() => exit, 'the distinct child exit')
|
||||
expect(connection.exitVerdict.root).toBe('exited')
|
||||
})
|
||||
|
||||
it('proves the exit of a child that ignores a graceful shutdown', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN])
|
||||
const connection = await open({
|
||||
...launchFor(scenario),
|
||||
env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_IGNORE_SIGTERM: '1' }
|
||||
})
|
||||
|
||||
// Keep the lstart capture boundary outside the child's displayed start second.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100))
|
||||
await expect(connection.close()).resolves.toBe(true)
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
// A structured Claude child owns the account's credentials while it runs, exactly as
|
||||
// a Claude PTY does. The gate is what makes runtime-auth-sync defer the managed OAuth
|
||||
// refresh instead of rotating the single-use token out from under a live session, and
|
||||
// structured sessions used to be invisible to it.
|
||||
describe('the managed-auth live gate', () => {
|
||||
it('holds while a structured child runs and releases when it ends', async () => {
|
||||
// The gate is a process-wide singleton and a sibling test's release lands on its
|
||||
// child's 'close' event, which can settle after that test's close() resolved.
|
||||
await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate')
|
||||
const scenario = scriptScenario([
|
||||
{ emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } },
|
||||
{ wait: HOLD_OPEN }
|
||||
])
|
||||
const connection = await open(launchFor(scenario))
|
||||
|
||||
expect(hasLiveClaudePtys()).toBe(true)
|
||||
|
||||
await connection.close()
|
||||
|
||||
await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain')
|
||||
expect(hasLiveClaudePtys()).toBe(false)
|
||||
}, 30_000)
|
||||
|
||||
it('releases when the child dies on its own rather than through close()', async () => {
|
||||
await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate')
|
||||
const scenario = scriptScenario([
|
||||
{ emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } },
|
||||
{ wait: HOLD_OPEN }
|
||||
])
|
||||
await open(launchFor(scenario))
|
||||
expect(hasLiveClaudePtys()).toBe(true)
|
||||
|
||||
spawnedChildren.at(-1)?.kill('SIGKILL')
|
||||
|
||||
await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain')
|
||||
expect(hasLiveClaudePtys()).toBe(false)
|
||||
}, 30_000)
|
||||
|
||||
// The gate entry is deliberately unpersisted, so confirmSeededClaudeLivePtys can never
|
||||
// reconcile a stray one: a leak here defers the managed OAuth refresh for the life of
|
||||
// the process. Entering the gate only after the release handlers are attached makes
|
||||
// that unreachable regardless of what the setup in between does.
|
||||
it('leaks no gate entry when setup throws between spawn and handler attachment', async () => {
|
||||
await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate')
|
||||
const scenario = scriptScenario([
|
||||
{ emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } },
|
||||
{ wait: HOLD_OPEN }
|
||||
])
|
||||
let started: SpawnedProcess | null = null
|
||||
|
||||
try {
|
||||
await expect(
|
||||
openClaudeStreamJsonConnection(launchFor(scenario), {}, (spec) => {
|
||||
const child = spawnProcess(spec)
|
||||
started = child
|
||||
const attach = child.stderr.on.bind(child.stderr)
|
||||
// Measured attach order: the SDK binds stderr 'data' from inside query(),
|
||||
// before the child is even assigned. The SECOND bind is this connection's own
|
||||
// armTreeOnOutput — the first statement that runs after the child exists and
|
||||
// before its 'exit'/'close' release handlers. Throwing on the first is
|
||||
// vacuous: it escapes before any gate entry could have happened.
|
||||
let dataAttaches = 0
|
||||
child.stderr.on = ((event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (event === 'data') {
|
||||
dataAttaches += 1
|
||||
if (dataAttaches === 2) {
|
||||
throw new Error('stderr listener attach failed')
|
||||
}
|
||||
}
|
||||
return attach(event, listener)
|
||||
}) as typeof child.stderr.on
|
||||
return child
|
||||
})
|
||||
).rejects.toThrow('stderr listener attach failed')
|
||||
|
||||
expect(hasLiveClaudePtys()).toBe(false)
|
||||
} finally {
|
||||
;(started as SpawnedProcess | null)?.kill('SIGKILL')
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import {
|
||||
markClaudeStructuredChildExited,
|
||||
markClaudeStructuredChildSpawned
|
||||
} from '../claude-accounts/live-pty-gate'
|
||||
import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
|
||||
import {
|
||||
ClaudeControlRequestError,
|
||||
createClaudeControlSurface,
|
||||
type ClaudeControlSurface
|
||||
} from './claude-agent-sdk-control-requests'
|
||||
import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof'
|
||||
import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification'
|
||||
import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn'
|
||||
import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue'
|
||||
import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution'
|
||||
|
||||
export { ClaudeControlRequestError }
|
||||
|
||||
/**
|
||||
* The SDK is loaded at the structured-Claude boundary rather than by this module's
|
||||
* import. The ordinary runtime's class graph statically reaches this file, and the
|
||||
* SDK sets `process.env.NoDefaultCurrentDirectoryInExePath` at import time — a
|
||||
* Windows executable-search change that a user who never leaves the terminal/TUI
|
||||
* path never opted into, and a missing SDK would fail runtime startup. Memoized,
|
||||
* so a session pays the import once per process rather than once per connection.
|
||||
*/
|
||||
let claudeAgentSdk: Promise<typeof ClaudeAgentSdk> | null = null
|
||||
|
||||
function loadClaudeAgentSdk(): Promise<typeof ClaudeAgentSdk> {
|
||||
claudeAgentSdk ??= import('@anthropic-ai/claude-agent-sdk')
|
||||
return claudeAgentSdk
|
||||
}
|
||||
|
||||
export type ClaudeStreamJsonLaunch = {
|
||||
/** Orca's resolved user CLI; the SDK falls back to a bundled binary that is not installed. */
|
||||
pathToClaudeCodeExecutable: string
|
||||
options: ClaudeStructuredSdkOptions
|
||||
cwd: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export type ClaudeStreamJsonConnectionHandlers = {
|
||||
onMessage?: (message: Record<string, unknown>) => void
|
||||
/**
|
||||
* The SDK owns inbound permission control: it hands `can_use_tool` to this callback with
|
||||
* a stable requestId and an abort signal, dedups duplicate delivery, and matches the
|
||||
* response by request_id itself. Setting it makes the SDK pass `--permission-prompt-tool
|
||||
* stdio` automatically; it must not be paired with `permissionPromptToolName`.
|
||||
*/
|
||||
canUseTool?: CanUseTool
|
||||
/** `request_user_dialog` control; the CLI only emits kinds declared in `supportedDialogKinds`. */
|
||||
onUserDialog?: OnUserDialog
|
||||
/** A transport/process fault that is not itself first-hand root exit proof. */
|
||||
onFault?: (error: Error) => void
|
||||
onExit?: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Two questions with their own evidence. The root's verdict is first-hand: Orca's
|
||||
* own child handle reported exit, or reported error then close before it ever had
|
||||
* a pid. The tree's comes from bounded descendant verification, and `unverifiable`
|
||||
* is never collapsed into either neighbour.
|
||||
*/
|
||||
export type ClaudeChildExitVerdict = {
|
||||
root: 'exited' | 'live' | 'processless'
|
||||
tree: DescendantTreeVerdict
|
||||
}
|
||||
|
||||
export type ClaudeStreamJsonConnection = ClaudeControlSurface & {
|
||||
readonly pid: number | undefined
|
||||
readonly closed: boolean
|
||||
/** What the ladder has observed so far; read after a `close()` that returned false. */
|
||||
readonly exitVerdict: ClaudeChildExitVerdict
|
||||
send: (message: Record<string, unknown>) => Promise<void>
|
||||
/** Resolves true after processless settlement, or root exit plus observed tree exit. */
|
||||
close: () => Promise<boolean>
|
||||
}
|
||||
|
||||
type ExitStatus = { code: number | null; signal: NodeJS.Signals | null }
|
||||
|
||||
function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error): Error {
|
||||
const detail = stderrTail.trim()
|
||||
// The status is the diagnostic a signed-out or refused start leaves behind;
|
||||
// it has to survive every wrapper between here and the user.
|
||||
const how =
|
||||
status?.signal !== null && status?.signal !== undefined
|
||||
? ` (signal ${status.signal})`
|
||||
: status?.code !== null && status?.code !== undefined
|
||||
? ` (code ${status.code})`
|
||||
: ''
|
||||
const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}`
|
||||
return cause ? new Error(message, { cause }) : new Error(message)
|
||||
}
|
||||
|
||||
export async function openClaudeStreamJsonConnection(
|
||||
launch: ClaudeStreamJsonLaunch,
|
||||
handlers: ClaudeStreamJsonConnectionHandlers = {},
|
||||
spawnImpl: typeof spawnProcess = spawnProcess,
|
||||
queryImpl?: typeof ClaudeAgentSdk.query
|
||||
): Promise<ClaudeStreamJsonConnection> {
|
||||
const { query } = await loadClaudeAgentSdk()
|
||||
const spawner = createClaudeCodeProcessSpawn(spawnImpl)
|
||||
const inbox = createClaudeUserMessageQueue()
|
||||
const session = (queryImpl ?? query)({
|
||||
prompt: inbox.messages,
|
||||
options: {
|
||||
...launch.options,
|
||||
cwd: launch.cwd,
|
||||
// Why env is never omitted: the SDK inherits process.env when it is, which is
|
||||
// exactly the ambient ANTHROPIC_* auth leak this lane already shipped once.
|
||||
env: buildClaudeChildProcessEnv(launch.env, { scrubConfiguredChildSessionStamps: true }),
|
||||
pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable,
|
||||
spawnClaudeCodeProcess: spawner.spawn,
|
||||
...(handlers.canUseTool ? { canUseTool: handlers.canUseTool } : {}),
|
||||
...(handlers.onUserDialog ? { onUserDialog: handlers.onUserDialog } : {})
|
||||
}
|
||||
})
|
||||
const child = spawner.child
|
||||
if (!child) {
|
||||
throw new Error('the claude agent SDK returned without spawning a child')
|
||||
}
|
||||
// This child owns the account's credentials for as long as it runs, exactly as a
|
||||
// Claude PTY does — hold the OAuth-refresh gate so a managed refresh cannot rotate
|
||||
// the single-use token out from under it mid-turn. Entered below, once a release
|
||||
// path exists.
|
||||
const authGateKey = randomUUID()
|
||||
const releaseAuthGate = (): void => markClaudeStructuredChildExited(authGateKey)
|
||||
let exited = false
|
||||
let exitStatus: ExitStatus | null = null
|
||||
let closing = false
|
||||
let processless = false
|
||||
let prePidSpawnError = false
|
||||
let terminalError: Error | null = null
|
||||
let faultReported = false
|
||||
let exitReported = false
|
||||
let closePromise: Promise<boolean> | null = null
|
||||
// One reaper per child: every close attempt and error-path reap shares its proof.
|
||||
const rootSettled = (): boolean => exited || processless
|
||||
const tree = createClaudeChildTreeReaper(child, { exited: rootSettled })
|
||||
|
||||
// Arm lazily on actual child output instead of issuing a process-table scan for
|
||||
// every session at startup. A natural SDK exit can race a later close, while
|
||||
// output-triggered observation still catches the usual live-child window.
|
||||
let outputObservationArmed = false
|
||||
const armTreeOnOutput = (): void => {
|
||||
if (outputObservationArmed) {
|
||||
return
|
||||
}
|
||||
outputObservationArmed = true
|
||||
void (tree.refresh?.() ?? tree.capture())
|
||||
}
|
||||
child.stderr.on('data', armTreeOnOutput)
|
||||
// The SDK may synchronously spawn the CLI and consume an early stderr chunk
|
||||
// before this connection can attach its listener; the bounded tail preserves
|
||||
// that observation for the same lazy arm.
|
||||
if (spawner.stderrTail.length > 0) {
|
||||
armTreeOnOutput()
|
||||
}
|
||||
|
||||
let settleExit = (): void => {}
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
settleExit = resolve
|
||||
})
|
||||
const markExited = (): void => {
|
||||
exited = true
|
||||
releaseAuthGate()
|
||||
settleExit()
|
||||
}
|
||||
child.on('exit', (code, signal) => {
|
||||
exitStatus = { code, signal }
|
||||
markExited()
|
||||
handleUnexpectedEnd()
|
||||
})
|
||||
|
||||
const handleUnexpectedEnd = (cause?: Error): void => {
|
||||
terminalError ??= exitError(spawner.stderrTail, exitStatus, cause)
|
||||
inbox.fail(terminalError)
|
||||
if (!closing && !faultReported) {
|
||||
faultReported = true
|
||||
handlers.onFault?.(terminalError)
|
||||
}
|
||||
if (!closing && exited && !exitReported) {
|
||||
exitReported = true
|
||||
handlers.onExit?.(terminalError)
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
for await (const message of session) {
|
||||
handlers.onMessage?.(message as unknown as Record<string, unknown>)
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
// The SDK ends its generator in error when the child dies or the transport
|
||||
// fails; a transport failure with a live child still has to reap the tree.
|
||||
if (!closing && !exited) {
|
||||
void tree.reap()
|
||||
}
|
||||
handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (spawner.pid === undefined) {
|
||||
prePidSpawnError = true
|
||||
}
|
||||
if (!closing && !exited) {
|
||||
void tree.reap()
|
||||
}
|
||||
handleUnexpectedEnd(error)
|
||||
})
|
||||
child.on('close', () => {
|
||||
// Covers the spawn-failure path too, where no 'exit' ever arrives.
|
||||
releaseAuthGate()
|
||||
if (prePidSpawnError && spawner.pid === undefined) {
|
||||
processless = true
|
||||
settleExit()
|
||||
}
|
||||
handleUnexpectedEnd()
|
||||
})
|
||||
child.stdin.on('error', (error) => {
|
||||
if (!closing) {
|
||||
void tree.reap()
|
||||
handleUnexpectedEnd(error)
|
||||
}
|
||||
})
|
||||
// Why here and not at spawn: a structured gate entry is deliberately unpersisted, so
|
||||
// confirmSeededClaudeLivePtys can never reconcile a stray one and a leak defers the
|
||||
// managed OAuth refresh for the life of the process. Entering only after 'exit' and
|
||||
// 'close' are attached makes that unreachable — any later throw still leaves a
|
||||
// listener that releases. Nothing between spawn and here can yield, so the child
|
||||
// cannot end before the gate is entered.
|
||||
markClaudeStructuredChildSpawned(authGateKey)
|
||||
|
||||
const send = (message: Record<string, unknown>): Promise<void> => {
|
||||
if (closing || exited || terminalError || child.stdin.destroyed || !child.stdin.writable) {
|
||||
return Promise.reject(terminalError ?? new Error('claude stream-json connection is closed'))
|
||||
}
|
||||
return inbox.push(message as unknown as SDKUserMessage)
|
||||
}
|
||||
|
||||
const close = (): Promise<boolean> => {
|
||||
closePromise ??= (async () => {
|
||||
closing = true
|
||||
// Arm the descendant proof before ending stdin. The SDK may exit the root
|
||||
// immediately; a post-exit walk cannot recover descendants that reparented.
|
||||
await (tree.refresh?.() ?? tree.capture())
|
||||
inbox.end()
|
||||
const proven = await proveClaudeChildExit({
|
||||
child,
|
||||
exitPromise,
|
||||
exited: rootSettled,
|
||||
tree
|
||||
})
|
||||
inbox.fail(new Error('claude stream-json connection closed'))
|
||||
if (!proven) {
|
||||
closePromise = null
|
||||
}
|
||||
return proven
|
||||
})()
|
||||
return closePromise
|
||||
}
|
||||
|
||||
return {
|
||||
...createClaudeControlSurface(session),
|
||||
get pid() {
|
||||
return spawner.pid
|
||||
},
|
||||
get closed() {
|
||||
return closing || exited || terminalError !== null
|
||||
},
|
||||
get exitVerdict() {
|
||||
return {
|
||||
root: processless ? 'processless' : exited ? 'exited' : 'live',
|
||||
tree: tree.treeVerdict
|
||||
} as const
|
||||
},
|
||||
send,
|
||||
close
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { claudeRecord, claudeText } from './claude-structured-item-translation'
|
||||
|
||||
// Under --include-partial-messages every stream_event frame carries its own
|
||||
// uuid, and the block's final `assistant` frame carries yet another; only
|
||||
// `message.id` ties them together. The block's first stream frame mints the
|
||||
// journal identity, and the final frame lands on it in block order instead of
|
||||
// appending a duplicate under its own uuid.
|
||||
|
||||
export type ClaudeStreamedTextDelta = { identity: AgentJournalItemIdentity; text: string }
|
||||
|
||||
type StreamedMessage = {
|
||||
messageId: string | null
|
||||
blocks: Map<number, AgentJournalItemIdentity>
|
||||
/** Streamed text blocks whose final assistant frame has not arrived, in block order. */
|
||||
awaitingFinal: AgentJournalItemIdentity[]
|
||||
}
|
||||
|
||||
export type ClaudeStreamedBlockRegistry = {
|
||||
/** Text a stream_event frame appends to its block, or null when it carries none. */
|
||||
observe: (frame: Record<string, unknown>) => ClaudeStreamedTextDelta | null
|
||||
/** The streamed identity a final assistant frame reconciles onto, if its block streamed. */
|
||||
reconcile: (frame: {
|
||||
sessionId: string
|
||||
parentToolUseId: string | null
|
||||
messageId: string | null
|
||||
}) => AgentJournalItemIdentity | null
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
function scopeKey(sessionId: string, parentToolUseId: string | null): string {
|
||||
return `${sessionId}/${parentToolUseId ?? ''}`
|
||||
}
|
||||
|
||||
export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry {
|
||||
const messages = new Map<string, StreamedMessage>()
|
||||
|
||||
const messageFor = (scope: string): StreamedMessage => {
|
||||
let streamed = messages.get(scope)
|
||||
if (!streamed) {
|
||||
streamed = { messageId: null, blocks: new Map(), awaitingFinal: [] }
|
||||
messages.set(scope, streamed)
|
||||
}
|
||||
return streamed
|
||||
}
|
||||
|
||||
const mint = (
|
||||
streamed: StreamedMessage,
|
||||
sessionId: string,
|
||||
index: number,
|
||||
uuid: string
|
||||
): AgentJournalItemIdentity => {
|
||||
const identity: AgentJournalItemIdentity = { provider: 'claude', sessionId, uuid }
|
||||
streamed.blocks.set(index, identity)
|
||||
streamed.awaitingFinal.push(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
return {
|
||||
observe: (frame) => {
|
||||
const event = claudeRecord(frame.event)
|
||||
const sessionId = claudeText(frame.session_id)
|
||||
const uuid = claudeText(frame.uuid)
|
||||
if (frame.type !== 'stream_event' || !event || !sessionId || !uuid) {
|
||||
return null
|
||||
}
|
||||
const scope = scopeKey(sessionId, claudeText(frame.parent_tool_use_id))
|
||||
if (event.type === 'message_start') {
|
||||
messages.set(scope, {
|
||||
messageId: claudeText(claudeRecord(event.message)?.id),
|
||||
blocks: new Map(),
|
||||
awaitingFinal: []
|
||||
})
|
||||
return null
|
||||
}
|
||||
const index = typeof event.index === 'number' ? event.index : 0
|
||||
if (event.type === 'content_block_start') {
|
||||
const block = claudeRecord(event.content_block)
|
||||
if (block?.type !== 'text') {
|
||||
return null
|
||||
}
|
||||
const identity = mint(messageFor(scope), sessionId, index, uuid)
|
||||
const text = claudeText(block.text)
|
||||
return text ? { identity, text } : null
|
||||
}
|
||||
if (event.type !== 'content_block_delta') {
|
||||
return null
|
||||
}
|
||||
const delta = claudeRecord(event.delta)
|
||||
const text = delta?.type === 'text_delta' ? claudeText(delta.text) : null
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const streamed = messageFor(scope)
|
||||
const identity = streamed.blocks.get(index) ?? mint(streamed, sessionId, index, uuid)
|
||||
return { identity, text }
|
||||
},
|
||||
reconcile: (frame) => {
|
||||
const streamed = messages.get(scopeKey(frame.sessionId, frame.parentToolUseId))
|
||||
if (
|
||||
!streamed ||
|
||||
(frame.messageId && streamed.messageId && frame.messageId !== streamed.messageId)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return streamed.awaitingFinal.shift() ?? null
|
||||
},
|
||||
clear: () => messages.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
|
||||
|
||||
function identityOf(uuid: string): AgentJournalItemIdentity {
|
||||
return { provider: 'claude', sessionId: 'claude-session', uuid }
|
||||
}
|
||||
|
||||
function checkpoints() {
|
||||
const rows: { uuid: string; text: string }[] = []
|
||||
let scheduled: (() => void) | null = null
|
||||
const store = createClaudeStreamedTextCheckpoints({
|
||||
persist: (identity, text) => {
|
||||
rows.push({ uuid: 'uuid' in identity ? identity.uuid : '', text })
|
||||
},
|
||||
schedule: (run) => {
|
||||
scheduled = run
|
||||
return () => {
|
||||
scheduled = null
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
store,
|
||||
rows,
|
||||
runWindow: () => {
|
||||
const run = scheduled as (() => void) | null
|
||||
run?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('claude streamed text checkpoints', () => {
|
||||
it('rewrites a block row with the full text accumulated so far', () => {
|
||||
const { store, rows, runWindow } = checkpoints()
|
||||
|
||||
store.append(identityOf('block-1'), 'hel')
|
||||
store.append(identityOf('block-1'), 'lo')
|
||||
runWindow()
|
||||
|
||||
expect(rows).toEqual([{ uuid: 'block-1', text: 'hello' }])
|
||||
expect(store.pending).toBe(1)
|
||||
})
|
||||
|
||||
it('drops every block still awaiting its final frame at settlement', () => {
|
||||
const { store, rows, runWindow } = checkpoints()
|
||||
|
||||
store.append(identityOf('block-1'), 'partial answer')
|
||||
runWindow()
|
||||
store.settle()
|
||||
|
||||
expect(store.pending).toBe(0)
|
||||
// The row written before settlement stays; nothing is rewritten afterwards.
|
||||
store.flush()
|
||||
expect(rows).toEqual([{ uuid: 'block-1', text: 'partial answer' }])
|
||||
})
|
||||
|
||||
it('keeps a block whose final frame arrived out of the settlement sweep', () => {
|
||||
const { store } = checkpoints()
|
||||
|
||||
store.append(identityOf('block-1'), 'one')
|
||||
store.append(identityOf('block-2'), 'two')
|
||||
store.forget('claude:claude-session:block-1')
|
||||
|
||||
expect(store.pending).toBe(1)
|
||||
store.settle()
|
||||
expect(store.pending).toBe(0)
|
||||
})
|
||||
|
||||
it('flushes text the widening checkpoint interval has not written yet', () => {
|
||||
const { store, rows } = checkpoints()
|
||||
|
||||
store.append(identityOf('block-1'), 'x')
|
||||
store.flush()
|
||||
|
||||
expect(rows).toEqual([{ uuid: 'block-1', text: 'x' }])
|
||||
// Already at the row's length: a second flush has nothing to write.
|
||||
store.flush()
|
||||
expect(rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stops persisting once disposed', () => {
|
||||
const { store, rows, runWindow } = checkpoints()
|
||||
|
||||
store.append(identityOf('block-1'), 'text')
|
||||
store.dispose()
|
||||
runWindow()
|
||||
store.flush()
|
||||
|
||||
expect(rows).toEqual([])
|
||||
expect(store.pending).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import {
|
||||
createAgentSessionDeltaCoalescer,
|
||||
type AgentSessionDeltaCoalescerDeps
|
||||
} from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
|
||||
|
||||
export type ClaudeStreamedTextCheckpointDeps = {
|
||||
/** Rewrites the block's journal row with the text accumulated so far. */
|
||||
persist: (identity: AgentJournalItemIdentity, text: string) => void
|
||||
coalesceMs?: number
|
||||
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
|
||||
}
|
||||
|
||||
export type ClaudeStreamedTextCheckpoints = {
|
||||
/** Accumulate a delta; the row is rewritten on the coalescer's own cadence. */
|
||||
append: (identity: AgentJournalItemIdentity, text: string) => void
|
||||
/** Write every block whose row is behind the text received for it. */
|
||||
flush: () => void
|
||||
/** Drop one block's state, for a block whose final frame has now landed. */
|
||||
forget: (key: string) => void
|
||||
/**
|
||||
* Drop every block still awaiting its final frame, at turn settlement. Their
|
||||
* text is already journaled by the flush that precedes settlement; keeping it
|
||||
* live would grow with every interrupted turn for the life of the session.
|
||||
*/
|
||||
settle: () => void
|
||||
/** Blocks still awaiting a final frame. A settled turn must leave none. */
|
||||
readonly pending: number
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Growth of a streamed block's row between its deltas and its final frame.
|
||||
*
|
||||
* The row is rewritten on a widening interval rather than per delta: a 200-line
|
||||
* reply would otherwise rewrite the same journal row once per token.
|
||||
*/
|
||||
export function createClaudeStreamedTextCheckpoints(
|
||||
deps: ClaudeStreamedTextCheckpointDeps
|
||||
): ClaudeStreamedTextCheckpoints {
|
||||
const identities = new Map<string, AgentJournalItemIdentity>()
|
||||
const latestText = new Map<string, string>()
|
||||
const checkpointLengths = new Map<string, number>()
|
||||
|
||||
const persist = (key: string, text: string, force: boolean): void => {
|
||||
latestText.set(key, text)
|
||||
const checkpointLength = checkpointLengths.get(key) ?? 0
|
||||
const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125))
|
||||
if (!force && checkpointLength > 0 && text.length < nextLength) {
|
||||
return
|
||||
}
|
||||
const identity = identities.get(key)
|
||||
if (!identity) {
|
||||
return
|
||||
}
|
||||
checkpointLengths.set(key, text.length)
|
||||
deps.persist(identity, text)
|
||||
}
|
||||
|
||||
const coalescer = createAgentSessionDeltaCoalescer({
|
||||
...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }),
|
||||
...(deps.schedule ? { schedule: deps.schedule } : {}),
|
||||
emit: (key, text) => persist(key, text, false)
|
||||
})
|
||||
|
||||
const drop = (key: string): void => {
|
||||
coalescer.forget(key)
|
||||
identities.delete(key)
|
||||
latestText.delete(key)
|
||||
checkpointLengths.delete(key)
|
||||
}
|
||||
|
||||
return {
|
||||
append: (identity, text) => {
|
||||
const key = agentJournalItemKey(identity)
|
||||
identities.set(key, identity)
|
||||
coalescer.append(key, text)
|
||||
},
|
||||
flush: () => {
|
||||
coalescer.flushAll()
|
||||
for (const [key, text] of latestText) {
|
||||
if (checkpointLengths.get(key) !== text.length) {
|
||||
persist(key, text, true)
|
||||
}
|
||||
}
|
||||
},
|
||||
forget: drop,
|
||||
settle: () => {
|
||||
// Map iteration tolerates deletion of the entry just visited.
|
||||
for (const key of identities.keys()) {
|
||||
drop(key)
|
||||
}
|
||||
},
|
||||
get pending() {
|
||||
return identities.size
|
||||
},
|
||||
dispose: () => {
|
||||
coalescer.dispose()
|
||||
identities.clear()
|
||||
latestText.clear()
|
||||
checkpointLengths.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
closeClaudeSession,
|
||||
claudeAcquisitionCleanupError
|
||||
} from './claude-structured-session-close'
|
||||
import type {
|
||||
ClaudeAcquisitionRegistry,
|
||||
ClaudeSession,
|
||||
ClaudeSessionExit,
|
||||
ClaudeStructuredSessionAdapterDeps
|
||||
} from './claude-structured-session-state'
|
||||
|
||||
/**
|
||||
* Cleanup for an acquisition the host could not commit or prove. A session that
|
||||
* a first-hand exit already removed is not an absence to report as proven: the
|
||||
* ladder on its connection still answers, and that answer is classified exactly
|
||||
* as a start-time failure would be.
|
||||
*/
|
||||
export async function releaseClaudeAcquisition(input: {
|
||||
sessionId: string
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
exits: Map<string, ClaudeSessionExit>
|
||||
onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
|
||||
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle']
|
||||
onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent']
|
||||
}): Promise<boolean> {
|
||||
const exit = input.exits.get(input.sessionId)
|
||||
if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) {
|
||||
return closeClaudeSession(input)
|
||||
}
|
||||
const firstProof = exit.closePromise ? await exit.closePromise : false
|
||||
// A failed exit-path proof is retained as evidence, not as a terminal result;
|
||||
// a release retry must drive a fresh tree verification on the same connection.
|
||||
const retriedProof = firstProof || (await exit.connection.close())
|
||||
if (retriedProof) {
|
||||
await input.onExitProven?.(input.sessionId, exit)
|
||||
// Keep the first-hand exit evidence indexed until the tree proof succeeds;
|
||||
// a failed close must be retryable and cannot look like an absent session.
|
||||
input.exits.delete(input.sessionId)
|
||||
return true
|
||||
}
|
||||
throw claudeAcquisitionCleanupError(exit.connection, exit.error)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
|
||||
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE
|
||||
} from '../claude-accounts/environment'
|
||||
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
||||
import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution'
|
||||
import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter'
|
||||
import {
|
||||
PROVIDER_SESSION_ID,
|
||||
adapterFor,
|
||||
fakeClaude,
|
||||
identityFor
|
||||
} from './claude-structured-session-test-support'
|
||||
|
||||
const SESSION_ID = 'orca-session-auth'
|
||||
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
|
||||
ReturnType<typeof createClaudeStructuredLaunchResolver>
|
||||
>[0]['identity']
|
||||
|
||||
function record(): AgentSessionRecord {
|
||||
return {
|
||||
sessionId: SESSION_ID,
|
||||
provider: 'claude',
|
||||
location: {
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' },
|
||||
providerHandleChain: []
|
||||
} as unknown as AgentSessionRecord
|
||||
}
|
||||
|
||||
function resolverFor(options: {
|
||||
stripAuthEnv: boolean
|
||||
overlay?: Record<string, string>
|
||||
authSwitchSettleTimeoutMs?: number
|
||||
}): ReturnType<typeof createClaudeStructuredLaunchResolver> {
|
||||
return createClaudeStructuredLaunchResolver({
|
||||
store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async (id) => `/repos/${id}`,
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }),
|
||||
authSwitchSettleTimeoutMs: options.authSwitchSettleTimeoutMs ?? 20,
|
||||
...(options.overlay ? { resolveEnv: () => options.overlay as Record<string, string> } : {})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* An adapter driven by the REAL launch resolver, not the stub in the shared test
|
||||
* support — the stub has no auth guard at all, so a teardown-window test built on it
|
||||
* would pass whatever the guard did.
|
||||
*/
|
||||
function realResolverAdapter(
|
||||
claude: ReturnType<typeof fakeClaude>,
|
||||
authSwitchSettleTimeoutMs: number
|
||||
): ClaudeStructuredSessionAdapter {
|
||||
const resumable = {
|
||||
...record(),
|
||||
providerHandleChain: [
|
||||
{ handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }
|
||||
]
|
||||
} as unknown as AgentSessionRecord
|
||||
return new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: createClaudeStructuredLaunchResolver({
|
||||
store: { getRecord: () => resumable } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async (id) => `/repos/${id}`,
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: false }),
|
||||
authSwitchSettleTimeoutMs
|
||||
}),
|
||||
openConnection: claude.openConnection,
|
||||
readProcessStartTime: async () => 1_700_000_000_000,
|
||||
now: () => 1_700_000_000_500,
|
||||
persistHandle: async () => {}
|
||||
})
|
||||
}
|
||||
|
||||
function withAmbientAuth<T>(value: string, run: () => Promise<T>): Promise<T> {
|
||||
const restore = process.env.ANTHROPIC_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = value
|
||||
return run().finally(() => {
|
||||
if (restore === undefined) {
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY = restore
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('claude structured auth parity with the terminal preflight', () => {
|
||||
afterEach(() => {
|
||||
endClaudeAuthSwitch()
|
||||
})
|
||||
|
||||
// Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and
|
||||
// runtime/spawn-preflight.ts:139; the structured path used to let the override win.
|
||||
it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => {
|
||||
await expect(
|
||||
resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({
|
||||
identity: IDENTITY
|
||||
})
|
||||
).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
})
|
||||
|
||||
it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => {
|
||||
await expect(
|
||||
resolverFor({
|
||||
stripAuthEnv: true,
|
||||
overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' }
|
||||
})({ identity: IDENTITY })
|
||||
).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
})
|
||||
|
||||
it('still admits a non-auth env overlay under a managed account', async () => {
|
||||
const launch = await resolverFor({
|
||||
stripAuthEnv: true,
|
||||
overlay: { ANTHROPIC_BASE_URL: 'https://gateway.example.test' }
|
||||
})({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_BASE_URL).toBe('https://gateway.example.test')
|
||||
})
|
||||
|
||||
// Task 2 — legacy computes stripAuthEnv at runtime-auth-preparation.ts:72, so a
|
||||
// system-auth user's own shell key is their sign-in and must survive.
|
||||
it('passes an ambient Anthropic key through when no managed account is active', async () => {
|
||||
await withAmbientAuth('sk-ant-SHELL', async () => {
|
||||
const launch = await resolverFor({ stripAuthEnv: false })({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL')
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an explicit overlay override the ambient key when no managed account is active', async () => {
|
||||
await withAmbientAuth('sk-ant-SHELL', async () => {
|
||||
const launch = await resolverFor({
|
||||
stripAuthEnv: false,
|
||||
overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' }
|
||||
})({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED')
|
||||
})
|
||||
})
|
||||
|
||||
it('still strips the ambient Anthropic key when a managed account is pinned', async () => {
|
||||
await withAmbientAuth('sk-ant-SHELL', async () => {
|
||||
const launch = await resolverFor({ stripAuthEnv: true })({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// Task 3 — the terminal preflight guards this at four sites; the structured path had none.
|
||||
it('refuses launch resolution when an account switch never settles', async () => {
|
||||
beginClaudeAuthSwitch()
|
||||
|
||||
await expect(
|
||||
resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY })
|
||||
).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
})
|
||||
|
||||
it('waits a settling account switch out rather than refusing a resolved launch', async () => {
|
||||
beginClaudeAuthSwitch()
|
||||
setTimeout(() => endClaudeAuthSwitch(), 20)
|
||||
|
||||
const launch = await resolverFor({
|
||||
stripAuthEnv: true,
|
||||
authSwitchSettleTimeoutMs: 5_000
|
||||
})({ identity: IDENTITY })
|
||||
|
||||
expect(launch.claudeConfigDir).toBe('/home/work/.claude')
|
||||
})
|
||||
|
||||
it('refuses an acquire before it tears the previous session down', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = adapterFor(claude)
|
||||
beginClaudeAuthSwitch()
|
||||
|
||||
await expect(
|
||||
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
// Nothing was spawned, so the refusal must not have opened a connection.
|
||||
expect(claude.connections).toHaveLength(0)
|
||||
})
|
||||
|
||||
// The teardown between the entry guard and launch resolution closes the live child
|
||||
// and proves its tree — seconds, not milliseconds. A switch that begins inside it
|
||||
// has already cost the user their session, so refusing there produces exactly the
|
||||
// outcome the entry guard advertises against: a dead chat and no replacement.
|
||||
it('replaces the session when a switch begins inside the acquire teardown', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = realResolverAdapter(claude, 5_000)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
const live = claude.connections[0]!
|
||||
const closeWithSwitch = live.close
|
||||
live.close = async () => {
|
||||
beginClaudeAuthSwitch()
|
||||
setTimeout(() => endClaudeAuthSwitch(), 20)
|
||||
return closeWithSwitch()
|
||||
}
|
||||
|
||||
await expect(
|
||||
adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' })
|
||||
).resolves.toMatchObject({ process: { spawnToken: 'spawn-10' } })
|
||||
expect(live.closed).toBe(true)
|
||||
// The replacement child exists: the user's chat came back.
|
||||
expect(claude.connections).toHaveLength(2)
|
||||
expect(claude.connections[1]!.closed).toBe(false)
|
||||
await adapter.closeAll()
|
||||
})
|
||||
|
||||
it('still refuses a mid-teardown switch that never settles, leaving nothing half-open', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = realResolverAdapter(claude, 20)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
const live = claude.connections[0]!
|
||||
const closeWithSwitch = live.close
|
||||
live.close = async () => {
|
||||
beginClaudeAuthSwitch()
|
||||
return closeWithSwitch()
|
||||
}
|
||||
|
||||
await expect(
|
||||
adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' })
|
||||
).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
// No replacement child was opened, so nothing is left running unowned.
|
||||
expect(claude.connections).toHaveLength(1)
|
||||
await adapter.closeAll()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
|
||||
function sinkState() {
|
||||
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body) => items.push({ identity, body }),
|
||||
appendTombstone: () => {},
|
||||
publish: vi.fn()
|
||||
}
|
||||
return { sink, items }
|
||||
}
|
||||
|
||||
function providerRows(items: { body: AgentJournalItemBody }[]) {
|
||||
return items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.providerFrame
|
||||
? [{ kind: item.body.providerFrame.kind, text: item.body.text }]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
function userMessageWith(part: unknown) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
startsTurn: true as const,
|
||||
message: {
|
||||
type: 'user',
|
||||
uuid: 'user-1',
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
isReplay: true,
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'look at this' }, part] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Exactly what claudeDispatchMessageContent sends for a local attachment. */
|
||||
const BASE64_IMAGE = {
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUg==' }
|
||||
}
|
||||
|
||||
describe('Claude message content parts', () => {
|
||||
it('does not leak a wire kind for a locally attached image', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userMessageWith(BASE64_IMAGE))
|
||||
|
||||
expect(providerRows(state.items)).toEqual([])
|
||||
})
|
||||
|
||||
it('still renders an image the CLI sends by url', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(
|
||||
userMessageWith({ type: 'image', source: { type: 'url', url: 'https://x.test/a.png' } })
|
||||
)
|
||||
|
||||
expect(providerRows(state.items)).toEqual([])
|
||||
expect(
|
||||
state.items.flatMap((item) => (item.body.kind === 'message' ? item.body.blocks : []))
|
||||
).toContainEqual({ type: 'image-ref', url: 'https://x.test/a.png' })
|
||||
})
|
||||
|
||||
it('says what is true for a content part it cannot render, not the wire kind', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userMessageWith({ type: 'some_future_part', payload: { a: 1 } }))
|
||||
|
||||
const rows = providerRows(state.items)
|
||||
expect(rows).toHaveLength(1)
|
||||
// The kind stays on the row for debugging, behind the disclosure.
|
||||
expect(rows[0].kind).toBe('message:user:content:some_future_part')
|
||||
// ...but the visible text is a sentence, not the opcode.
|
||||
expect(rows[0].text).not.toContain('message:user:content')
|
||||
expect(rows[0].text.toLowerCase()).toContain('claude')
|
||||
})
|
||||
|
||||
it('prefers a readable sentence the part carries over the placeholder', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(
|
||||
userMessageWith({ type: 'some_future_part', message: 'the server refused the upload' })
|
||||
)
|
||||
|
||||
expect(providerRows(state.items)[0].text).toBe('the server refused the upload')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { cancelClaudeTurn, answerClaudePrompt } from './claude-structured-control-actions'
|
||||
import { ClaudeControlRequestError } from './claude-stream-json-connection'
|
||||
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
type InterruptResult = Awaited<ReturnType<ClaudeSession['connection']['interrupt']>>
|
||||
|
||||
function sessionWith(input: {
|
||||
capabilities?: string[]
|
||||
interrupt: (options?: { cancelQueued?: boolean; timeoutMs?: number }) => Promise<InterruptResult>
|
||||
cancelAsyncMessage?: (uuid: string) => Promise<void>
|
||||
prompts?: ClaudePromptRegistry
|
||||
}): {
|
||||
session: ClaudeSession
|
||||
interrupt: ReturnType<typeof vi.fn>
|
||||
cancelAsyncMessage: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const interrupt = vi.fn(input.interrupt)
|
||||
const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => {}))
|
||||
const session = {
|
||||
capabilities: input.capabilities ?? [],
|
||||
prompts: input.prompts ?? new ClaudePromptRegistry(),
|
||||
connection: { interrupt, cancelAsyncMessage }
|
||||
} as unknown as ClaudeSession
|
||||
return { session, interrupt, cancelAsyncMessage }
|
||||
}
|
||||
|
||||
describe('cancelClaudeTurn', () => {
|
||||
it('interrupts without a receipt on an older CLI and reports the turn cancelled', async () => {
|
||||
const { session, interrupt, cancelAsyncMessage } = sessionWith({
|
||||
interrupt: async () => undefined
|
||||
})
|
||||
|
||||
await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true })
|
||||
expect(interrupt).toHaveBeenCalledWith({ timeoutMs: 5_000 })
|
||||
expect(cancelAsyncMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('withdraws every still-queued message a plain interrupt receipt reports', async () => {
|
||||
const { session, interrupt, cancelAsyncMessage } = sessionWith({
|
||||
capabilities: ['interrupt_receipt_v1'],
|
||||
interrupt: async () => ({ still_queued: ['queued-1', 'queued-2'] })
|
||||
})
|
||||
|
||||
await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true })
|
||||
// No cancel_queued capability, so the queue is swept one uuid at a time.
|
||||
expect(interrupt).toHaveBeenCalledWith({ timeoutMs: 5_000 })
|
||||
expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2'])
|
||||
})
|
||||
|
||||
it('sends cancel_queued and never sweeps when the CLI advertises the capability', async () => {
|
||||
const { session, interrupt, cancelAsyncMessage } = sessionWith({
|
||||
capabilities: ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1'],
|
||||
interrupt: async () => ({ still_queued: [], cancelled: ['queued-1'] })
|
||||
})
|
||||
|
||||
await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true })
|
||||
expect(interrupt).toHaveBeenCalledWith({ cancelQueued: true, timeoutMs: 5_000 })
|
||||
expect(cancelAsyncMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a not-running interrupt as not cancelled without throwing', async () => {
|
||||
const { session } = sessionWith({
|
||||
interrupt: async () => {
|
||||
throw new ClaudeControlRequestError('interrupt', 'not running')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: false })
|
||||
})
|
||||
|
||||
it('propagates a transport failure such as an interrupt timeout', async () => {
|
||||
const { session } = sessionWith({
|
||||
interrupt: async () => {
|
||||
throw new Error('claude interrupt request timed out')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(cancelClaudeTurn(session, 5_000)).rejects.toThrow('timed out')
|
||||
})
|
||||
})
|
||||
|
||||
describe('answerClaudePrompt', () => {
|
||||
it('settles the pending prompt callback and forgets it', async () => {
|
||||
const prompts = new ClaudePromptRegistry()
|
||||
const settle = vi.fn()
|
||||
const prompt = prompts.register({
|
||||
requestId: 'perm-1',
|
||||
toolName: 'Bash',
|
||||
toolUseId: 'tool-1',
|
||||
input: { command: 'ls' },
|
||||
suggestions: [],
|
||||
settle
|
||||
})!
|
||||
prompts.bindJournalItemId('journal-1', prompt.promptKey)
|
||||
const { session } = sessionWith({ interrupt: async () => undefined, prompts })
|
||||
|
||||
await answerClaudePrompt(session, { itemId: 'journal-1', kind: 'approval', optionId: 'allow' })
|
||||
|
||||
expect(settle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' })
|
||||
)
|
||||
expect(prompts.find('journal-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses an answer for a prompt Claude is no longer waiting on', async () => {
|
||||
const { session } = sessionWith({ interrupt: async () => undefined })
|
||||
await expect(
|
||||
answerClaudePrompt(session, { itemId: 'missing', kind: 'approval', optionId: 'allow' })
|
||||
).rejects.toThrow(/no longer waiting/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { applyClaudePromptAnswer } from './claude-structured-prompt-replies'
|
||||
import { ClaudeControlRequestError } from './claude-stream-json-connection'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1'
|
||||
|
||||
export type ClaudeTurnCancellationGuard = () => boolean
|
||||
|
||||
/**
|
||||
* Interrupt the running turn, then make sure no queued async user message survives to spawn a
|
||||
* later unexpected turn. On a CLI advertising `interrupt_cancel_queued_v1` one round trip
|
||||
* cancels the queue alongside the abort; otherwise the interrupt receipt lists `still_queued`
|
||||
* uuids, and each is withdrawn best-effort with `cancel_async_message`. Older CLIs resolve no
|
||||
* receipt, so there is nothing to sweep.
|
||||
*/
|
||||
export async function cancelClaudeTurn(
|
||||
session: ClaudeSession,
|
||||
timeoutMs: number | undefined,
|
||||
isCurrent: ClaudeTurnCancellationGuard = () => true
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
// The SDK interrupt is session-scoped. Re-check the caller's turn/fence
|
||||
// immediately before issuing it so a delayed request cannot stop a later turn.
|
||||
if (!isCurrent()) {
|
||||
return { cancelled: false }
|
||||
}
|
||||
const cancelQueued = session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY)
|
||||
try {
|
||||
const receipt = await session.connection.interrupt({
|
||||
...(cancelQueued ? { cancelQueued: true } : {}),
|
||||
timeoutMs
|
||||
})
|
||||
if (!cancelQueued) {
|
||||
for (const uuid of receipt?.still_queued ?? []) {
|
||||
await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => {})
|
||||
}
|
||||
}
|
||||
return { cancelled: true }
|
||||
} catch (error) {
|
||||
if (error instanceof ClaudeControlRequestError) {
|
||||
return { cancelled: false }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function answerClaudePrompt(
|
||||
session: ClaudeSession,
|
||||
input: { itemId: string; kind: 'approval' | 'question'; optionId: string }
|
||||
): Promise<void> {
|
||||
const found = session.prompts.find(input.itemId)
|
||||
if (!found || found.prompt.kind !== input.kind) {
|
||||
throw new Error(`claude is no longer waiting on ${input.itemId}`)
|
||||
}
|
||||
const response = applyClaudePromptAnswer(found, input.optionId)
|
||||
if (response === null) {
|
||||
return
|
||||
}
|
||||
session.prompts.forget(found.prompt)
|
||||
found.prompt.settle(response)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { open } from 'node:fs/promises'
|
||||
import { extname } from 'node:path'
|
||||
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
||||
import type { NativeChatBlock } from '../../shared/native-chat-types'
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
const MAX_IMAGE_COUNT = 20
|
||||
const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
const MAX_REPLAY_CONTENT_KEY_BYTES = 256
|
||||
|
||||
type ImageBudget = {
|
||||
count: number
|
||||
localBytes: number
|
||||
}
|
||||
|
||||
export async function readClaudeImage(path: string, openImpl: typeof open = open): Promise<Buffer> {
|
||||
const file = await openImpl(path, 'r')
|
||||
try {
|
||||
const invalidImage = (): Error =>
|
||||
new Error(`Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`)
|
||||
const info = await file.stat()
|
||||
if (!info.isFile()) {
|
||||
throw new Error('Claude image must be a file')
|
||||
}
|
||||
if (info.size > MAX_IMAGE_BYTES) {
|
||||
throw invalidImage()
|
||||
}
|
||||
const buffer = Buffer.allocUnsafe(info.size + 1)
|
||||
let bytesRead = 0
|
||||
while (bytesRead < buffer.length) {
|
||||
const result = await file.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead)
|
||||
if (result.bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
bytesRead += result.bytesRead
|
||||
}
|
||||
// A file can grow after the initial stat and after the final read returns
|
||||
// zero. Prove the descriptor's size matches what was copied before sending.
|
||||
const finalInfo = await file.stat()
|
||||
if (bytesRead === 0 || bytesRead > MAX_IMAGE_BYTES || finalInfo.size !== bytesRead) {
|
||||
throw invalidImage()
|
||||
}
|
||||
return buffer.subarray(0, bytesRead)
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||
'.gif': 'image/gif',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp'
|
||||
}
|
||||
|
||||
async function imageContent(
|
||||
block: Extract<NativeChatBlock, { type: 'image-ref' }>,
|
||||
budget: ImageBudget
|
||||
): Promise<unknown> {
|
||||
budget.count += 1
|
||||
if (budget.count > MAX_IMAGE_COUNT) {
|
||||
throw new Error(`Claude messages support at most ${MAX_IMAGE_COUNT} images`)
|
||||
}
|
||||
if (block.url) {
|
||||
return { type: 'image', source: { type: 'url', url: block.url } }
|
||||
}
|
||||
if (!block.path) {
|
||||
throw new Error('image reference has neither a path nor a URL')
|
||||
}
|
||||
const data = await readClaudeImage(block.path)
|
||||
budget.localBytes += data.byteLength
|
||||
if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) {
|
||||
throw new Error(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`)
|
||||
}
|
||||
const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()]
|
||||
if (!mediaType) {
|
||||
throw new Error(`Claude does not support the image type ${extname(block.path)}`)
|
||||
}
|
||||
return {
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mediaType,
|
||||
data: data.toString('base64')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function claudeDispatchMessageContent(
|
||||
body: AgentJournalMessageItem
|
||||
): Promise<unknown[]> {
|
||||
if (body.role !== 'user') {
|
||||
throw new Error('Claude dispatch accepts only user messages')
|
||||
}
|
||||
const content: unknown[] = []
|
||||
const imageBudget: ImageBudget = { count: 0, localBytes: 0 }
|
||||
for (const block of body.blocks as NativeChatBlock[]) {
|
||||
if (block.type === 'text' && block.text.length > 0) {
|
||||
content.push({ type: 'text', text: block.text })
|
||||
} else if (block.type === 'image-ref') {
|
||||
content.push(await imageContent(block, imageBudget))
|
||||
}
|
||||
}
|
||||
if (content.length === 0) {
|
||||
throw new Error('Claude dispatch requires text or an image')
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep waiter metadata bounded even when a dispatch contains large base64 images.
|
||||
* The digest is only diagnostic: replay acknowledgement must use provider identity.
|
||||
*/
|
||||
export function claudeDispatchContentKey(content: readonly unknown[]): string {
|
||||
const digest = createHash('sha256')
|
||||
const summary = content
|
||||
.map((part) => {
|
||||
const record =
|
||||
typeof part === 'object' && part !== null && !Array.isArray(part)
|
||||
? (part as Record<string, unknown>)
|
||||
: null
|
||||
const type = typeof record?.type === 'string' ? record.type : 'unknown'
|
||||
if (type === 'text') {
|
||||
return `text:${typeof record?.text === 'string' ? record.text.length : 0}`
|
||||
}
|
||||
const source =
|
||||
typeof record?.source === 'object' && record.source !== null
|
||||
? (record.source as Record<string, unknown>)
|
||||
: null
|
||||
if (type === 'image' && source?.type === 'base64') {
|
||||
return `image:${typeof source.media_type === 'string' ? source.media_type : ''}:${typeof source.data === 'string' ? source.data.length : 0}`
|
||||
}
|
||||
return type
|
||||
})
|
||||
.join(',')
|
||||
for (const [index, part] of content.entries()) {
|
||||
const record =
|
||||
typeof part === 'object' && part !== null && !Array.isArray(part)
|
||||
? (part as Record<string, unknown>)
|
||||
: null
|
||||
const type = typeof record?.type === 'string' ? record.type : 'unknown'
|
||||
digest.update(`${index}:${type}:`)
|
||||
if (type === 'text' && typeof record?.text === 'string') {
|
||||
digest.update(record.text)
|
||||
continue
|
||||
}
|
||||
const source =
|
||||
typeof record?.source === 'object' && record.source !== null
|
||||
? (record.source as Record<string, unknown>)
|
||||
: null
|
||||
if (type === 'image' && source?.type === 'base64') {
|
||||
digest.update(typeof source.media_type === 'string' ? source.media_type : '')
|
||||
digest.update(':')
|
||||
if (typeof source.data === 'string') {
|
||||
digest.update(source.data)
|
||||
}
|
||||
continue
|
||||
}
|
||||
digest.update(JSON.stringify(part))
|
||||
}
|
||||
const key = `v1:${summary.slice(0, 128)}:${digest.digest('hex')}`
|
||||
return key.slice(0, MAX_REPLAY_CONTENT_KEY_BYTES)
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
||||
import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch'
|
||||
import { readClaudeImage } from './claude-structured-dispatch-content'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession {
|
||||
return {
|
||||
connection: { send } as unknown as ClaudeSession['connection'],
|
||||
providerSessionId: 'provider-session',
|
||||
claudeConfigDir: '/accounts/claude',
|
||||
leafUuid: null,
|
||||
fence: 1,
|
||||
acquisitionGeneration: 'generation-1',
|
||||
prompts: {} as ClaudeSession['prompts'],
|
||||
dispatchWaiters: [],
|
||||
retiredDispatchWaiters: [],
|
||||
replayContentFallbackBlocked: false,
|
||||
dispatchSequence: 0,
|
||||
optionMutationSequence: 0,
|
||||
options: new Map(),
|
||||
reportedOptions: {},
|
||||
reportedModelMutation: 0,
|
||||
confirmedOptions: new Set(),
|
||||
restoreSkippedOptions: new Set(),
|
||||
capabilities: [],
|
||||
events: undefined,
|
||||
translator: null
|
||||
}
|
||||
}
|
||||
|
||||
function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'user', blocks }
|
||||
}
|
||||
|
||||
function userReplayFrame(uuid: string, text: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'user',
|
||||
parent_tool_use_id: null,
|
||||
session_id: 'provider-session',
|
||||
uuid,
|
||||
message: { role: 'user', content: [{ type: 'text', text }] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude structured dispatch image limits', () => {
|
||||
it('recovers the active identity when a timed-out replay arrives late', async () => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
|
||||
500
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
|
||||
await expect(dispatched).resolves.toMatchObject({ state: 'unknown' })
|
||||
|
||||
expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(true)
|
||||
expect(session.activeTurnId).toBe(sentUuid)
|
||||
expect(session.activeTurnSequence).toBe(session.dispatchSequence)
|
||||
})
|
||||
|
||||
it('never lets a late replay for dispatch A resolve dispatch B', async () => {
|
||||
const session = sessionFor()
|
||||
const first = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
|
||||
500
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
|
||||
await expect(first).resolves.toMatchObject({ state: 'unknown' })
|
||||
|
||||
const second = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
|
||||
|
||||
expect(resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'))).toBe(false)
|
||||
expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
|
||||
expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'))).toBe(true)
|
||||
await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } })
|
||||
})
|
||||
|
||||
it('does not let an identical late replay for dispatch A resolve active dispatch B', async () => {
|
||||
const session = sessionFor()
|
||||
const first = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
|
||||
500
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
await expect(first).resolves.toMatchObject({ state: 'unknown' })
|
||||
|
||||
const second = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const secondUuid = session.dispatchWaiters[0]!.sentUuid
|
||||
|
||||
expect(resolveClaudeReplayWaiter(session, userReplayFrame('provider-a', 'same prompt'))).toBe(
|
||||
false
|
||||
)
|
||||
expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
|
||||
|
||||
resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'))
|
||||
await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } })
|
||||
})
|
||||
|
||||
it('does not let a fresh-UUID replay for an evicted dispatch resolve active dispatch B', async () => {
|
||||
const session = sessionFor()
|
||||
const first = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
await expect(first).resolves.toMatchObject({ state: 'unknown' })
|
||||
const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid
|
||||
|
||||
const fillerDispatches = await Promise.all(
|
||||
Array.from({ length: 64 }, (_, index) =>
|
||||
dispatchClaudeTurn(
|
||||
session,
|
||||
{
|
||||
clientMessageId: `filler-${index}`,
|
||||
body: userMessage([{ type: 'text', text: 'same prompt' }])
|
||||
},
|
||||
5
|
||||
)
|
||||
)
|
||||
)
|
||||
expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true)
|
||||
expect(session.retiredDispatchWaiters).toHaveLength(64)
|
||||
expect(session.replayContentFallbackBlocked).toBe(true)
|
||||
expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
while (session.retiredDispatchWaiters.length > 0) {
|
||||
const sentUuid = session.retiredDispatchWaiters[0]!.sentUuid
|
||||
resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid, 'same prompt'))
|
||||
}
|
||||
expect(session.retiredDispatchWaiters).toHaveLength(0)
|
||||
|
||||
const second = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const secondUuid = session.dispatchWaiters[0]!.sentUuid
|
||||
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, userReplayFrame('provider-a-late', 'same prompt'))
|
||||
).toBe(false)
|
||||
expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
|
||||
|
||||
resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'))
|
||||
await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } })
|
||||
})
|
||||
|
||||
it('does not let a fresh-UUID result for an evicted slash dispatch resolve active dispatch B', async () => {
|
||||
const session = sessionFor()
|
||||
const first = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
await expect(first).resolves.toMatchObject({ state: 'unknown' })
|
||||
const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid
|
||||
|
||||
const fillerDispatches = await Promise.all(
|
||||
Array.from({ length: 64 }, (_, index) =>
|
||||
dispatchClaudeTurn(
|
||||
session,
|
||||
{
|
||||
clientMessageId: `filler-${index}`,
|
||||
body: userMessage([{ type: 'text', text: '/permissions' }])
|
||||
},
|
||||
5
|
||||
)
|
||||
)
|
||||
)
|
||||
expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true)
|
||||
expect(session.retiredDispatchWaiters).toHaveLength(64)
|
||||
expect(session.replayContentFallbackBlocked).toBe(true)
|
||||
expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
while (session.retiredDispatchWaiters.length > 0) {
|
||||
const sentUuid = session.retiredDispatchWaiters[0]!.sentUuid
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'provider-session',
|
||||
uuid: `result-${sentUuid}`,
|
||||
user_message_uuid: sentUuid
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
expect(session.retiredDispatchWaiters).toHaveLength(0)
|
||||
|
||||
const second = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const secondUuid = session.dispatchWaiters[0]!.sentUuid
|
||||
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'result-a-late'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
|
||||
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'result-b',
|
||||
user_message_uuid: secondUuid
|
||||
})
|
||||
).toBe(false)
|
||||
await expect(second).resolves.toMatchObject({
|
||||
providerIdentity: { uuid: 'result-b' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let a legacy result for timed-out ordinary dispatch A resolve slash dispatch B', async () => {
|
||||
const session = sessionFor()
|
||||
const first = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'ordinary' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
await expect(first).resolves.toMatchObject({ state: 'unknown' })
|
||||
|
||||
const second = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'legacy-result-a'
|
||||
})
|
||||
).toBe(false)
|
||||
await expect(second).resolves.toMatchObject({ state: 'unknown' })
|
||||
})
|
||||
|
||||
it('removes only its own waiter when a later send fails', async () => {
|
||||
const session = sessionFor()
|
||||
const first = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const firstWaiter = session.dispatchWaiters[0]
|
||||
session.connection.send = vi.fn().mockRejectedValue(new Error('broken pipe'))
|
||||
|
||||
await expect(
|
||||
dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) },
|
||||
100
|
||||
)
|
||||
).resolves.toMatchObject({ state: 'unknown', reason: 'broken pipe' })
|
||||
expect(session.dispatchWaiters).toEqual([firstWaiter])
|
||||
|
||||
const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid
|
||||
resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'))
|
||||
await expect(first).resolves.toMatchObject({ providerIdentity: { uuid: firstUuid } })
|
||||
})
|
||||
|
||||
it('keeps a replay accepted before its send reports failure', async () => {
|
||||
let session!: ClaudeSession
|
||||
const send = vi.fn(async (message: Record<string, unknown>) => {
|
||||
resolveClaudeReplayWaiter(session, { ...message, uuid: 'turn-race' })
|
||||
throw new Error('write raced provider acknowledgement')
|
||||
})
|
||||
session = sessionFor(send)
|
||||
|
||||
await expect(
|
||||
dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
|
||||
100
|
||||
)
|
||||
).resolves.toMatchObject({ state: 'accepted', providerIdentity: { uuid: 'turn-race' } })
|
||||
expect(session.dispatchWaiters).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts a slash command from its result receipt when Claude omits the user replay', async () => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'command-result-uuid'
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
await expect(dispatched).resolves.toEqual({
|
||||
state: 'accepted',
|
||||
providerIdentity: {
|
||||
provider: 'claude',
|
||||
sessionId: 'provider-session',
|
||||
uuid: 'command-result-uuid'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => {
|
||||
const session = sessionFor()
|
||||
const first = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
|
||||
500
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
await expect(first).resolves.toMatchObject({ state: 'unknown' })
|
||||
|
||||
const second = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) },
|
||||
500
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const secondUuid = session.dispatchWaiters[0]!.sentUuid
|
||||
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'result-b',
|
||||
user_message_uuid: secondUuid
|
||||
})
|
||||
).toBe(false)
|
||||
await expect(second).resolves.toMatchObject({
|
||||
state: 'accepted',
|
||||
providerIdentity: { uuid: 'result-b' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mistake a normal turn result for its missing user replay', async () => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'hello' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'unrelated-result-uuid'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(session.dispatchWaiters).toHaveLength(1)
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'user',
|
||||
parent_tool_use_id: null,
|
||||
session_id: 'provider-session',
|
||||
uuid: 'user-replay-uuid',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }]
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
await expect(dispatched).resolves.toMatchObject({
|
||||
state: 'accepted',
|
||||
providerIdentity: { uuid: 'user-replay-uuid' }
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a top-level tool-result user frame while waiting for a slash command replay', async () => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'user',
|
||||
parent_tool_use_id: null,
|
||||
session_id: 'provider-session',
|
||||
uuid: 'tool-result-uuid',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }]
|
||||
}
|
||||
})
|
||||
expect(session.dispatchWaiters).toHaveLength(1)
|
||||
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'user',
|
||||
parent_tool_use_id: null,
|
||||
session_id: 'provider-session',
|
||||
uuid: 'user-replay-uuid',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '/permissions' }]
|
||||
}
|
||||
})
|
||||
|
||||
await expect(dispatched).resolves.toEqual({
|
||||
state: 'accepted',
|
||||
providerIdentity: {
|
||||
provider: 'claude',
|
||||
sessionId: 'provider-session',
|
||||
uuid: 'user-replay-uuid'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects more than twenty URL images before sending', async () => {
|
||||
const session = sessionFor()
|
||||
const body = userMessage(
|
||||
Array.from({ length: 21 }, (_, index) => ({
|
||||
type: 'image-ref' as const,
|
||||
url: `https://example.test/${index}.png`
|
||||
}))
|
||||
)
|
||||
|
||||
await expect(
|
||||
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
|
||||
).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' })
|
||||
expect(session.connection.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects local images whose aggregate size exceeds twenty MiB', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-'))
|
||||
try {
|
||||
const paths = await Promise.all(
|
||||
Array.from({ length: 5 }, async (_, index) => {
|
||||
const path = join(directory, `${index}.png`)
|
||||
await writeFile(path, Buffer.alloc(5 * 1024 * 1024))
|
||||
return path
|
||||
})
|
||||
)
|
||||
const session = sessionFor()
|
||||
const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path })))
|
||||
|
||||
await expect(
|
||||
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
|
||||
).resolves.toEqual({
|
||||
state: 'rejected',
|
||||
reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes`
|
||||
})
|
||||
expect(session.connection.send).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a local image by actual bytes read beyond the per-image cap', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-'))
|
||||
try {
|
||||
const path = join(directory, 'oversized.png')
|
||||
await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1))
|
||||
const session = sessionFor()
|
||||
const body = userMessage([{ type: 'image-ref', path }])
|
||||
|
||||
await expect(
|
||||
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
|
||||
).resolves.toEqual({
|
||||
state: 'rejected',
|
||||
reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes`
|
||||
})
|
||||
expect(session.connection.send).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('allocates local image reads from the file size, not the maximum cap', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-'))
|
||||
const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe')
|
||||
try {
|
||||
const path = join(directory, 'small.png')
|
||||
await writeFile(path, Buffer.alloc(64))
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'image-ref', path }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
...userReplayFrame(sentUuid!, ''),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '' } }
|
||||
]
|
||||
}
|
||||
})
|
||||
await expect(dispatched).resolves.toMatchObject({ state: 'accepted' })
|
||||
expect(allocUnsafe).toHaveBeenCalled()
|
||||
expect(allocUnsafe.mock.calls.some(([size]) => size === 64 + 1)).toBe(true)
|
||||
expect(allocUnsafe.mock.calls.some(([size]) => size >= 5 * 1024 * 1024)).toBe(false)
|
||||
} finally {
|
||||
allocUnsafe.mockRestore()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds retained waiter identity bytes when image dispatches time out', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-'))
|
||||
try {
|
||||
const path = join(directory, 'large.png')
|
||||
await writeFile(path, Buffer.alloc(64 * 1024))
|
||||
const session = sessionFor()
|
||||
const body = userMessage([{ type: 'image-ref', path }])
|
||||
await Promise.all(
|
||||
Array.from({ length: 64 }, (_, index) =>
|
||||
dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, body }, 1)
|
||||
)
|
||||
)
|
||||
|
||||
expect(session.retiredDispatchWaiters).toHaveLength(64)
|
||||
const retainedKeyBytes = session.retiredDispatchWaiters.reduce(
|
||||
(total, waiter) => total + waiter.replayContentKey.length,
|
||||
0
|
||||
)
|
||||
expect(retainedKeyBytes).toBeLessThan(64 * 512)
|
||||
expect(
|
||||
session.retiredDispatchWaiters.every((waiter) => waiter.replayContentKey.length < 512)
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a local image when it grows after the initial stat', async () => {
|
||||
const stat = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ isFile: () => true, size: 64 })
|
||||
.mockResolvedValueOnce({ isFile: () => true, size: 128 })
|
||||
const read = vi.fn(async (buffer: Buffer, offset: number) => {
|
||||
if (read.mock.calls.length === 1) {
|
||||
buffer.fill(1, offset, offset + 64)
|
||||
return { bytesRead: 64, buffer }
|
||||
}
|
||||
return { bytesRead: 0, buffer }
|
||||
})
|
||||
const open = vi.fn().mockResolvedValue({
|
||||
stat,
|
||||
read,
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
} as never)
|
||||
await expect(readClaudeImage('/controlled/growing.png', open)).rejects.toThrow(
|
||||
`Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import {
|
||||
claudeHasReplayContent,
|
||||
readClaudeMessageEnvelope
|
||||
} from './claude-structured-item-translation'
|
||||
import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state'
|
||||
import { readClaudeFrameString } from './claude-structured-init-proof'
|
||||
import {
|
||||
claudeDispatchContentKey,
|
||||
claudeDispatchMessageContent
|
||||
} from './claude-structured-dispatch-content'
|
||||
|
||||
const MAX_RETIRED_DISPATCH_WAITERS = 64
|
||||
|
||||
export function resolveClaudeReplayWaiter(
|
||||
session: ClaudeSession,
|
||||
message: Record<string, unknown>
|
||||
): boolean {
|
||||
const envelope = readClaudeMessageEnvelope(message)
|
||||
const isUserReplay =
|
||||
envelope?.role === 'user' &&
|
||||
message.parent_tool_use_id === null &&
|
||||
claudeHasReplayContent(envelope)
|
||||
const isCompletedCommand = message.type === 'result'
|
||||
if (
|
||||
(!isUserReplay && !isCompletedCommand) ||
|
||||
readClaudeFrameString(message, 'session_id') !== session.providerSessionId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const uuid = readClaudeFrameString(message, 'uuid')
|
||||
if (!uuid) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Newer SDK frames carry the client uuid that caused a turn. A correlation
|
||||
// value is authoritative: never fall back to queue order or content, since
|
||||
// identical prompts may be in flight across a timeout boundary.
|
||||
const userMessageUuid = readClaudeFrameString(message, 'user_message_uuid')
|
||||
if (userMessageUuid) {
|
||||
const exact = session.dispatchWaiters.find(
|
||||
(candidate) => candidate.sentUuid === userMessageUuid
|
||||
)
|
||||
if (exact) {
|
||||
settleWaiter(session, exact, uuid)
|
||||
return isUserReplay && exact.dispatchSequence === session.dispatchSequence
|
||||
}
|
||||
const retired = session.retiredDispatchWaiters.find(
|
||||
(candidate) => candidate.sentUuid === userMessageUuid
|
||||
)
|
||||
if (retired) {
|
||||
forgetRetiredWaiter(session, retired)
|
||||
return recoverLateIdentity(session, retired, uuid, isUserReplay)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid)
|
||||
if (exact) {
|
||||
settleWaiter(session, exact, uuid)
|
||||
return isUserReplay && exact.dispatchSequence === session.dispatchSequence
|
||||
}
|
||||
const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid)
|
||||
if (retired) {
|
||||
forgetRetiredWaiter(session, retired)
|
||||
return recoverLateIdentity(session, retired, uuid, isUserReplay)
|
||||
}
|
||||
|
||||
if (isUserReplay) {
|
||||
// Compatibility CLIs may mint a new replay uuid instead of echoing the
|
||||
// client uuid. Content is an acceptable join only when it is the sole
|
||||
// candidate on one side of the timeout boundary; with active and retired
|
||||
// candidates present, identical prompts are intentionally left unknown.
|
||||
const replayContentKey = claudeDispatchContentKey(envelope.content)
|
||||
if (!session.replayContentFallbackBlocked && session.retiredDispatchWaiters.length === 0) {
|
||||
const compatible = session.dispatchWaiters.filter(
|
||||
(candidate) => candidate.replayContentKey === replayContentKey
|
||||
)
|
||||
if (compatible.length === 1) {
|
||||
settleWaiter(session, compatible[0]!, uuid)
|
||||
return compatible[0]!.dispatchSequence === session.dispatchSequence
|
||||
}
|
||||
} else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) {
|
||||
const lateCompatible = session.retiredDispatchWaiters.filter(
|
||||
(candidate) => candidate.replayContentKey === replayContentKey
|
||||
)
|
||||
if (lateCompatible.length === 1) {
|
||||
const [candidate] = lateCompatible
|
||||
forgetRetiredWaiter(session, candidate!)
|
||||
return recoverLateIdentity(session, candidate!, uuid, true)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const current = session.dispatchWaiters[0]
|
||||
if (isCompletedCommand && !current?.acceptsResult) {
|
||||
return false
|
||||
}
|
||||
// A legacy result has no dispatch correlation. Any retired waiter makes queue order ambiguous,
|
||||
// even when the retired dispatch was an ordinary turn rather than a slash command.
|
||||
if (isCompletedCommand && session.retiredDispatchWaiters.length > 0) {
|
||||
return false
|
||||
}
|
||||
// Once an eviction occurred, a fresh result uuid cannot be joined to a waiter by queue order.
|
||||
if (isCompletedCommand && session.replayContentFallbackBlocked) {
|
||||
return false
|
||||
}
|
||||
const waiter = uuid ? session.dispatchWaiters.shift() : undefined
|
||||
if (waiter && uuid) {
|
||||
clearTimeout(waiter.timer)
|
||||
waiter.settledUuid = uuid
|
||||
waiter.resolve(uuid)
|
||||
return isUserReplay
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function settleWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter, uuid: string): void {
|
||||
const index = session.dispatchWaiters.indexOf(waiter)
|
||||
if (index !== -1) {
|
||||
session.dispatchWaiters.splice(index, 1)
|
||||
}
|
||||
clearTimeout(waiter.timer)
|
||||
waiter.settledUuid = uuid
|
||||
waiter.resolve(uuid)
|
||||
}
|
||||
|
||||
function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void {
|
||||
const index = session.retiredDispatchWaiters.indexOf(waiter)
|
||||
if (index !== -1) {
|
||||
session.retiredDispatchWaiters.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function recoverLateIdentity(
|
||||
session: ClaudeSession,
|
||||
waiter: ClaudeDispatchWaiter,
|
||||
uuid: string,
|
||||
isUserReplay: boolean
|
||||
): boolean {
|
||||
if (!isUserReplay && !waiter.acceptsResult) {
|
||||
return false
|
||||
}
|
||||
if (waiter.dispatchSequence === session.dispatchSequence) {
|
||||
session.activeTurnId = uuid
|
||||
session.activeTurnSequence = waiter.dispatchSequence
|
||||
}
|
||||
return isUserReplay && waiter.dispatchSequence === session.dispatchSequence
|
||||
}
|
||||
|
||||
function waitForReplay(
|
||||
session: ClaudeSession,
|
||||
timeoutMs: number,
|
||||
acceptsResult: boolean,
|
||||
sentUuid: string,
|
||||
replayContentKey: string
|
||||
): { waiter: ClaudeDispatchWaiter; promise: Promise<string | null> } {
|
||||
let waiter!: ClaudeDispatchWaiter
|
||||
const promise = new Promise<string | null>((resolve) => {
|
||||
waiter = {
|
||||
acceptsResult,
|
||||
sentUuid,
|
||||
dispatchSequence: session.dispatchSequence,
|
||||
replayContentKey,
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
const index = session.dispatchWaiters.indexOf(waiter)
|
||||
if (index !== -1) {
|
||||
session.dispatchWaiters.splice(index, 1)
|
||||
}
|
||||
retireWaiter(session, waiter)
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
}
|
||||
waiter.timer.unref?.()
|
||||
session.dispatchWaiters.push(waiter)
|
||||
})
|
||||
return { waiter, promise }
|
||||
}
|
||||
|
||||
function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void {
|
||||
const index = session.dispatchWaiters.indexOf(waiter)
|
||||
if (index !== -1) {
|
||||
session.dispatchWaiters.splice(index, 1)
|
||||
}
|
||||
clearTimeout(waiter.timer)
|
||||
if (!waiter.retired) {
|
||||
waiter.retired = true
|
||||
session.retiredDispatchWaiters.push(waiter)
|
||||
if (session.retiredDispatchWaiters.length > MAX_RETIRED_DISPATCH_WAITERS) {
|
||||
session.replayContentFallbackBlocked = true
|
||||
session.retiredDispatchWaiters.splice(
|
||||
0,
|
||||
session.retiredDispatchWaiters.length - MAX_RETIRED_DISPATCH_WAITERS
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatchClaudeTurn(
|
||||
session: ClaudeSession,
|
||||
input: { clientMessageId: string; body: AgentJournalMessageItem },
|
||||
timeoutMs: number
|
||||
): Promise<AgentSessionDispatchOutcome> {
|
||||
let content: unknown[]
|
||||
try {
|
||||
content = await claudeDispatchMessageContent(input.body)
|
||||
} catch (error) {
|
||||
return { state: 'rejected', reason: (error as Error).message }
|
||||
}
|
||||
const dispatchSequence = ++session.dispatchSequence
|
||||
const acceptsResult = input.body.blocks.some(
|
||||
(block) => block.type === 'text' && block.text.trimStart().startsWith('/')
|
||||
)
|
||||
const sentUuid = randomUUID()
|
||||
const replay = waitForReplay(
|
||||
session,
|
||||
timeoutMs,
|
||||
acceptsResult,
|
||||
sentUuid,
|
||||
claudeDispatchContentKey(content)
|
||||
)
|
||||
const replayed = replay.promise
|
||||
try {
|
||||
await session.connection.send({
|
||||
type: 'user',
|
||||
uuid: sentUuid,
|
||||
message: { role: 'user', content },
|
||||
parent_tool_use_id: null,
|
||||
session_id: session.providerSessionId
|
||||
})
|
||||
} catch (error) {
|
||||
const waiter = replay.waiter
|
||||
if (waiter.settledUuid) {
|
||||
const uuid = await replayed
|
||||
if (uuid) {
|
||||
session.activeTurnId = uuid
|
||||
session.activeTurnSequence = dispatchSequence
|
||||
return {
|
||||
state: 'accepted',
|
||||
providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!waiter.retired) {
|
||||
retireWaiter(session, waiter)
|
||||
waiter.resolve(null)
|
||||
}
|
||||
return { state: 'unknown', reason: (error as Error).message }
|
||||
}
|
||||
const uuid = await replayed
|
||||
if (uuid) {
|
||||
session.activeTurnId = uuid
|
||||
session.activeTurnSequence = dispatchSequence
|
||||
}
|
||||
return uuid
|
||||
? {
|
||||
state: 'accepted',
|
||||
providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid }
|
||||
}
|
||||
: { state: 'unknown', reason: 'claude accepted a message but did not replay its uuid in time' }
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
|
||||
import {
|
||||
restoreClaudeStructuredSessionOptions,
|
||||
setClaudeStructuredOption
|
||||
} from './claude-structured-options'
|
||||
import { readClaudeSettingsEffort } from './claude-structured-session-options'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter'
|
||||
import { acquired, fakeClaude } from './claude-structured-session-test-support'
|
||||
|
||||
/** Verbatim from Claude Code 2.1.258's get_settings response. */
|
||||
const REAL_SETTINGS = {
|
||||
applied: { model: 'claude-opus-5[1m]', effort: 'high', advisor: null, ultracode: false },
|
||||
effective: { model: 'claude-opus-5[1m]', effortLevel: 'high', env: {} },
|
||||
sources: {}
|
||||
}
|
||||
|
||||
function sessionWith(
|
||||
reported: string | null,
|
||||
calls: string[] = [],
|
||||
listed?: { model: string; catalog: readonly Record<string, unknown>[] }
|
||||
) {
|
||||
return {
|
||||
session: {
|
||||
options: new Map<string, string>(listed ? [['model', listed.model]] : []),
|
||||
reportedOptions: {} as { model?: string; effort?: string },
|
||||
optionMutationSequence: 0,
|
||||
reportedModelMutation: 0,
|
||||
confirmedOptions: new Set<string>(),
|
||||
restoreSkippedOptions: new Set<string>(),
|
||||
connection: {
|
||||
supportedModels: async () => {
|
||||
calls.push('list_models')
|
||||
return [...(listed?.catalog ?? [])]
|
||||
},
|
||||
setModel: async (model: string) => {
|
||||
calls.push(`set_model:${model}`)
|
||||
},
|
||||
applyFlagSettings: async (settings: { effortLevel?: string }) => {
|
||||
// The measured behaviour: an unknown effort is accepted and ignored.
|
||||
calls.push(`apply:${settings.effortLevel}`)
|
||||
},
|
||||
getSettings: async () => {
|
||||
calls.push('get_settings')
|
||||
return reported === null
|
||||
? { applied: {}, effective: {}, sources: {} }
|
||||
: { applied: { effort: reported }, effective: { effortLevel: reported }, sources: {} }
|
||||
}
|
||||
}
|
||||
} as unknown as ClaudeSession,
|
||||
calls
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude effort reporting', () => {
|
||||
it('reads the effort get_settings reports', () => {
|
||||
expect(readClaudeSettingsEffort(REAL_SETTINGS)).toBe('high')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'the provider stops reporting it',
|
||||
{ applied: { effort: 'high' }, effective: {}, sources: {} }
|
||||
],
|
||||
['the payload carries no effective block', { applied: { effort: 'high' } }],
|
||||
['the request failed outright', null]
|
||||
])('reports no effort when %s', (_case, settings) => {
|
||||
// Never defaulted: an effort nothing measured would be worse than a blank
|
||||
// pill, and this is the assertion that goes red if the key is renamed.
|
||||
expect(readClaudeSettingsEffort(settings)).toBeNull()
|
||||
})
|
||||
|
||||
it('publishes the effort from get_settings, which system/init never carries', async () => {
|
||||
const claude = fakeClaude({ settings: REAL_SETTINGS })
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
|
||||
current: { effort: 'high' }
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the effort unreported when the session never learns one', async () => {
|
||||
const claude = fakeClaude({ settings: { applied: {}, effective: {}, sources: {} } })
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(options.current.effort).toBeUndefined()
|
||||
expect(options.current.model).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the init fixture free of an effort the real frame never sends', async () => {
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
await acquired(fakeClaude(), {}, events)
|
||||
const init = events.flatMap((event) =>
|
||||
event.type === 'message' && event.message.subtype === 'init' ? [event.message] : []
|
||||
)
|
||||
|
||||
expect(init).toHaveLength(1)
|
||||
expect(init[0]).toHaveProperty('model')
|
||||
// The regression that hid this defect: a fixture inventing `effortLevel`
|
||||
// kept every gate green over a value that is always empty in production.
|
||||
expect(Object.keys(init[0])).not.toContain('effortLevel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Claude effort readback', () => {
|
||||
it('records an effort the child did not adopt without vouching for it', async () => {
|
||||
const { session, calls } = sessionWith('high')
|
||||
|
||||
// The disagreement stops the confirmation, not the write: no other client
|
||||
// vetoes here, and the pre-flight catalog guard already refuses the levels
|
||||
// the model cannot run.
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'bogus-effort-xyz' }, undefined)
|
||||
).resolves.toEqual({ effort: 'bogus-effort-xyz' })
|
||||
expect(session.confirmedOptions.has('effort')).toBe(false)
|
||||
// The child's own answer is kept rather than discarded with the refusal.
|
||||
expect(session.reportedOptions.effort).toBe('high')
|
||||
expect(calls).toEqual(['apply:bogus-effort-xyz', 'get_settings'])
|
||||
})
|
||||
|
||||
it('records an effort the child confirms', async () => {
|
||||
const { session } = sessionWith('low')
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
|
||||
).resolves.toEqual({ effort: 'low' })
|
||||
})
|
||||
|
||||
it('records the request when the readback is unavailable', async () => {
|
||||
// No evidence of a refusal is not evidence of one; the apply itself succeeded.
|
||||
const { session } = sessionWith(null)
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
|
||||
).resolves.toEqual({ effort: 'low' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Claude effort against the model that must run it', () => {
|
||||
const HAIKU = { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' }
|
||||
const SONNET = {
|
||||
value: 'sonnet',
|
||||
resolvedModel: 'claude-sonnet-5',
|
||||
displayName: 'Sonnet',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
}
|
||||
|
||||
it('refuses an effort the current model advertises no control for', async () => {
|
||||
const { session, calls } = sessionWith('high', [], { model: 'haiku', catalog: [HAIKU, SONNET] })
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
|
||||
).rejects.toBeInstanceOf(AgentSessionOptionRejectedError)
|
||||
// Measured on Claude Code 2.1.260: apply_flag_settings stores `high` on a
|
||||
// haiku session and get_settings reads it straight back, so a send here is
|
||||
// never undone. The refusal has to land before the write.
|
||||
expect(calls).toEqual(['list_models'])
|
||||
expect(session.options.has('effort')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a level outside the ones the current model advertises', async () => {
|
||||
const { session } = sessionWith('high', [], {
|
||||
model: 'sonnet',
|
||||
catalog: [{ ...SONNET, supportedEffortLevels: ['low', 'medium'] }]
|
||||
})
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
|
||||
).rejects.toBeInstanceOf(AgentSessionOptionRejectedError)
|
||||
})
|
||||
|
||||
it('sends an effort the current model advertises', async () => {
|
||||
const { session, calls } = sessionWith('high', [], {
|
||||
model: 'sonnet',
|
||||
catalog: [HAIKU, SONNET]
|
||||
})
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
|
||||
).resolves.toEqual({ model: 'sonnet', effort: 'high' })
|
||||
expect(calls).toEqual(['list_models', 'apply:high', 'get_settings'])
|
||||
expect(session.confirmedOptions.has('effort')).toBe(true)
|
||||
})
|
||||
|
||||
it('sends `max`, which the readback cannot report, when the model advertises it', async () => {
|
||||
// UNREPORTED_EFFORTS still governs: no get_settings, so no false disagreement.
|
||||
const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [SONNET] })
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined)
|
||||
).resolves.toEqual({ model: 'sonnet', effort: 'max' })
|
||||
expect(calls).toEqual(['list_models', 'apply:max'])
|
||||
expect(session.confirmedOptions.has('effort')).toBe(false)
|
||||
})
|
||||
|
||||
it('sends the effort when the model is not in the catalog the CLI listed', async () => {
|
||||
// An unlisted model is an unknown one, not one that refuses effort.
|
||||
const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU] })
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
|
||||
).resolves.toEqual({ model: 'sonnet', effort: 'high' })
|
||||
expect(calls).toEqual(['list_models', 'apply:high', 'get_settings'])
|
||||
})
|
||||
|
||||
it('sends the effort when list_models is unavailable', async () => {
|
||||
const calls: string[] = []
|
||||
const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [] })
|
||||
session.connection.supportedModels = async () => {
|
||||
calls.push('list_models')
|
||||
throw new Error('this CLI predates list_models')
|
||||
}
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
|
||||
).resolves.toEqual({ model: 'sonnet', effort: 'high' })
|
||||
expect(calls).toEqual(['list_models', 'apply:high', 'get_settings'])
|
||||
})
|
||||
|
||||
it('matches the model the init frame reported, not just the id the user picked', async () => {
|
||||
const { session } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU, SONNET] })
|
||||
session.options.delete('model')
|
||||
session.reportedOptions.model = 'claude-haiku-4-5-20251001'
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined)
|
||||
).rejects.toBeInstanceOf(AgentSessionOptionRejectedError)
|
||||
})
|
||||
|
||||
it('keeps a disagreeing effort through restore instead of skipping it', async () => {
|
||||
const calls: string[] = []
|
||||
const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [SONNET] })
|
||||
session.options.set('effort', 'low')
|
||||
|
||||
await restoreClaudeStructuredSessionOptions(session, undefined)
|
||||
|
||||
expect(session.options.get('effort')).toBe('low')
|
||||
expect(session.restoreSkippedOptions.has('effort')).toBe(false)
|
||||
expect(session.confirmedOptions.has('effort')).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a stale effort on restore instead of replaying it onto the new model', async () => {
|
||||
const calls: string[] = []
|
||||
const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [HAIKU, SONNET] })
|
||||
session.options.set('model', 'haiku')
|
||||
session.options.set('effort', 'high')
|
||||
|
||||
await restoreClaudeStructuredSessionOptions(session, undefined)
|
||||
|
||||
expect(session.options.has('effort')).toBe(false)
|
||||
expect(session.restoreSkippedOptions.has('effort')).toBe(true)
|
||||
expect(calls.filter((call) => call.startsWith('apply:'))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import {
|
||||
buildClaudePermissionCallbacks,
|
||||
CLAUDE_BLOCKING_CONTROL_CALLBACKS,
|
||||
CLAUDE_CAN_USE_TOOL_SUBTYPE,
|
||||
CLAUDE_REQUEST_USER_DIALOG_SUBTYPE
|
||||
} from './claude-structured-inbound-control'
|
||||
|
||||
type CanUseToolOptions = Parameters<CanUseTool>[2]
|
||||
|
||||
function permissionOptions(
|
||||
requestId: string,
|
||||
toolUseID: string,
|
||||
signal: AbortSignal,
|
||||
suggestions?: unknown[]
|
||||
): CanUseToolOptions {
|
||||
return {
|
||||
requestId,
|
||||
toolUseID,
|
||||
signal,
|
||||
...(suggestions ? { suggestions } : {})
|
||||
} as unknown as CanUseToolOptions
|
||||
}
|
||||
|
||||
function callbacksFor() {
|
||||
const prompts = new ClaudePromptRegistry()
|
||||
const emit = vi.fn()
|
||||
const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({
|
||||
sessionId: 'session-1',
|
||||
prompts,
|
||||
emit
|
||||
})
|
||||
return { prompts, emit, canUseTool, onUserDialog }
|
||||
}
|
||||
|
||||
describe('Claude permission callbacks', () => {
|
||||
it('registers a decodable can_use_tool as a durable prompt and settles it from the registry', async () => {
|
||||
const control = callbacksFor()
|
||||
const answered = control.canUseTool(
|
||||
'Bash',
|
||||
{ command: 'git status' },
|
||||
permissionOptions('perm-1', 'tool-1', new AbortController().signal, [{ type: 'addRules' }])
|
||||
)
|
||||
|
||||
expect(control.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'prompt',
|
||||
sessionId: 'session-1',
|
||||
prompt: expect.objectContaining({ promptKey: 'perm-1', toolName: 'Bash', kind: 'approval' })
|
||||
})
|
||||
)
|
||||
const found = control.prompts.find('perm-1')
|
||||
expect(found?.prompt.suggestions).toEqual([{ type: 'addRules' }])
|
||||
// The prompt's settle is the SDK callback's own resolve — answering resolves this promise.
|
||||
found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' })
|
||||
await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' })
|
||||
})
|
||||
|
||||
it('denies a malformed permission request without registering a prompt', async () => {
|
||||
const control = callbacksFor()
|
||||
const answered = control.canUseTool(
|
||||
'',
|
||||
{},
|
||||
permissionOptions('perm-2', 'tool-2', new AbortController().signal)
|
||||
)
|
||||
|
||||
await expect(answered).resolves.toEqual({
|
||||
behavior: 'deny',
|
||||
message: 'Orca could not decode this permission request.',
|
||||
toolUseID: 'tool-2'
|
||||
})
|
||||
expect(control.prompts.find('perm-2')).toBeNull()
|
||||
expect(control.emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settles a pending prompt with null and forgets it when the abort signal fires', async () => {
|
||||
const control = callbacksFor()
|
||||
const controller = new AbortController()
|
||||
const answered = control.canUseTool(
|
||||
'Bash',
|
||||
{ command: 'ls' },
|
||||
permissionOptions('perm-3', 'tool-3', controller.signal)
|
||||
)
|
||||
expect(control.prompts.find('perm-3')).not.toBeNull()
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(answered).resolves.toBeNull()
|
||||
expect(control.emit).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-3' })
|
||||
)
|
||||
// Forgotten: a late answer can no longer find the prompt to authorize the wrong tool.
|
||||
expect(control.prompts.find('perm-3')).toBeNull()
|
||||
})
|
||||
|
||||
it('cancels a request whose abort raced ahead of delivery without emitting a prompt', async () => {
|
||||
const control = callbacksFor()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const answered = control.canUseTool(
|
||||
'Bash',
|
||||
{ command: 'ls' },
|
||||
permissionOptions('perm-4', 'tool-4', controller.signal)
|
||||
)
|
||||
|
||||
await expect(answered).resolves.toBeNull()
|
||||
expect(control.prompts.find('perm-4')).toBeNull()
|
||||
expect(control.emit).toHaveBeenCalledTimes(1)
|
||||
expect(control.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-4' })
|
||||
)
|
||||
})
|
||||
|
||||
it('settles every in-flight prompt with null when the registry is cleared', async () => {
|
||||
const control = callbacksFor()
|
||||
const first = control.canUseTool(
|
||||
'Bash',
|
||||
{ command: 'a' },
|
||||
permissionOptions('perm-5', 'tool-5', new AbortController().signal)
|
||||
)
|
||||
const second = control.canUseTool(
|
||||
'Bash',
|
||||
{ command: 'b' },
|
||||
permissionOptions('perm-6', 'tool-6', new AbortController().signal)
|
||||
)
|
||||
|
||||
// What session close does: settle each pending callback so no promise dangles.
|
||||
for (const prompt of control.prompts.clear()) {
|
||||
prompt.settle(null)
|
||||
}
|
||||
|
||||
await expect(first).resolves.toBeNull()
|
||||
await expect(second).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('answers a user dialog deny-safe', async () => {
|
||||
const control = callbacksFor()
|
||||
await expect(
|
||||
control.onUserDialog(
|
||||
{ dialogKind: 'refusal_fallback_prompt', payload: {} },
|
||||
{ signal: new AbortController().signal, requestId: 'dialog-1' }
|
||||
)
|
||||
).resolves.toEqual({ behavior: 'cancelled' })
|
||||
})
|
||||
|
||||
it('enumerates every blocking control request and wires a callback for each', () => {
|
||||
// The stable surface of controls a turn can block on. Adding one here without wiring its
|
||||
// callback below fails this test rather than silently leaving a control unhandled.
|
||||
expect(new Set(Object.keys(CLAUDE_BLOCKING_CONTROL_CALLBACKS))).toEqual(
|
||||
new Set([CLAUDE_CAN_USE_TOOL_SUBTYPE, CLAUDE_REQUEST_USER_DIALOG_SUBTYPE])
|
||||
)
|
||||
const callbacks = buildClaudePermissionCallbacks({
|
||||
sessionId: 'session-1',
|
||||
prompts: new ClaudePromptRegistry(),
|
||||
emit: vi.fn()
|
||||
}) as unknown as Record<string, unknown>
|
||||
for (const callbackName of Object.values(CLAUDE_BLOCKING_CONTROL_CALLBACKS)) {
|
||||
expect(typeof callbacks[callbackName], `${callbackName} must be wired`).toBe('function')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { CanUseTool, OnUserDialog, PermissionResult } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
|
||||
|
||||
export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool'
|
||||
export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog'
|
||||
|
||||
/**
|
||||
* The blocking control requests Orca answers, each mapped to the SDK consumer callback that
|
||||
* answers it. This is the stable surface a real turn can block on: `can_use_tool` through
|
||||
* `canUseTool` and `request_user_dialog` through `onUserDialog`. Every other control-request
|
||||
* subtype the SDK routes (elicitation, oauth/host token refresh, mcp_message, hook_callback)
|
||||
* is either not surfaced to this consumer or fails closed inside the SDK; adding a new
|
||||
* blocking control Orca must answer means adding its callback here, and the catalog test
|
||||
* fails if a named callback is missing.
|
||||
*/
|
||||
export const CLAUDE_BLOCKING_CONTROL_CALLBACKS = {
|
||||
[CLAUDE_CAN_USE_TOOL_SUBTYPE]: 'canUseTool',
|
||||
[CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]: 'onUserDialog'
|
||||
} as const
|
||||
|
||||
export type ClaudeBlockingControlSubtype = keyof typeof CLAUDE_BLOCKING_CONTROL_CALLBACKS
|
||||
|
||||
export type ClaudePermissionCallbackDeps = {
|
||||
sessionId: string
|
||||
prompts: ClaudePromptRegistry
|
||||
emit: (event: ClaudeStructuredSessionEvent) => void
|
||||
}
|
||||
|
||||
function denySafeResult(toolUseId: string | undefined): PermissionResult {
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Orca could not decode this permission request.',
|
||||
...(toolUseId ? { toolUseID: toolUseId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SDK permission callbacks from the durable prompt registry.
|
||||
*
|
||||
* A decodable `can_use_tool` becomes a durable prompt whose `settle` resolves this callback;
|
||||
* a malformed one is denied without registering. The SDK's abort signal fires on
|
||||
* `control_cancel_request` (a cancelled turn), which forgets the prompt and settles it with
|
||||
* `null` — never authorizing a tool. A late answer after abort finds no prompt and is refused
|
||||
* by `answerClaudePrompt`. `onUserDialog` is deny-safe; the CLI only emits dialog kinds Orca
|
||||
* declares in `supportedDialogKinds`, which is empty.
|
||||
*/
|
||||
export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDeps): {
|
||||
canUseTool: CanUseTool
|
||||
onUserDialog: OnUserDialog
|
||||
} {
|
||||
const canUseTool: CanUseTool = (toolName, input, options) =>
|
||||
new Promise<PermissionResult | null>((resolve) => {
|
||||
const prompt = deps.prompts.register({
|
||||
requestId: options.requestId,
|
||||
toolName,
|
||||
toolUseId: options.toolUseID,
|
||||
input,
|
||||
suggestions: options.suggestions ?? [],
|
||||
settle: resolve as (response: Record<string, unknown> | null) => void
|
||||
})
|
||||
if (!prompt) {
|
||||
resolve(denySafeResult(options.toolUseID))
|
||||
return
|
||||
}
|
||||
const cancel = (): void => {
|
||||
if (deps.prompts.forgetIfPending(prompt)) {
|
||||
deps.emit({
|
||||
type: 'prompt-cancelled',
|
||||
sessionId: deps.sessionId,
|
||||
promptKey: prompt.promptKey
|
||||
})
|
||||
// Null is the SDK's "no response written" sentinel: a cancelled request must not
|
||||
// be answered, only forgotten.
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
if (options.signal.aborted) {
|
||||
// No abort event can still fire, so registering a listener would park the callback
|
||||
// forever behind a prompt nothing will answer.
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
options.signal.addEventListener('abort', cancel, { once: true })
|
||||
deps.emit({ type: 'prompt', sessionId: deps.sessionId, prompt })
|
||||
})
|
||||
|
||||
const onUserDialog: OnUserDialog = () => Promise.resolve({ behavior: 'cancelled' })
|
||||
|
||||
return { canUseTool, onUserDialog }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ClaudeInitObservation } from './claude-structured-init-proof'
|
||||
import { claudeInitializationAuthError } from './claude-structured-init-proof'
|
||||
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
|
||||
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
|
||||
export type ClaudeInitDeadline = {
|
||||
promise: Promise<ClaudeInitObservation>
|
||||
resolve: (init: ClaudeInitObservation) => void
|
||||
reject: (error: Error) => void
|
||||
start: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function claudeInitTimeoutError(
|
||||
sessionId: string,
|
||||
timeoutMs: number
|
||||
): AgentSessionAcquisitionRefusal {
|
||||
return new AgentSessionAcquisitionRefusal(
|
||||
`Claude did not finish starting session ${sessionId} within ${Math.ceil(timeoutMs / 1000)} seconds. Verify the selected Claude account is signed in and CLAUDE_CONFIG_DIR contains valid credentials, then retry; no SessionStart or system/init proof arrived.`
|
||||
)
|
||||
}
|
||||
|
||||
export async function requestClaudeInitialization(
|
||||
connection: ClaudeStreamJsonConnection,
|
||||
sessionId: string,
|
||||
timeoutMs: number
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const result = await connection.initializationResult({ timeoutMs })
|
||||
const authError = claudeInitializationAuthError(result)
|
||||
if (authError) {
|
||||
throw authError
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'claude initialize request timed out') {
|
||||
throw claudeInitTimeoutError(sessionId, timeoutMs)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function createClaudeInitDeadline(sessionId: string, timeoutMs: number): ClaudeInitDeadline {
|
||||
let resolve = (_init: ClaudeInitObservation): void => {}
|
||||
let reject = (_error: Error): void => {}
|
||||
const promise = new Promise<ClaudeInitObservation>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
void promise.catch(() => {})
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
start: () => {
|
||||
timer = setTimeout(() => reject(claudeInitTimeoutError(sessionId, timeoutMs)), timeoutMs)
|
||||
timer.unref?.()
|
||||
},
|
||||
clear: () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution'
|
||||
import type { ClaudeAuthDiagnostic } from './claude-structured-session-state'
|
||||
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
|
||||
export type ClaudeInitObservation = {
|
||||
providerSessionId: string
|
||||
uuid: string | null
|
||||
/** The resolved model id the CLI reports it is running; only `system/init` carries it. */
|
||||
model: string | null
|
||||
message: Record<string, unknown>
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function readClaudeFrameString(source: Record<string, unknown>, key: string): string | null {
|
||||
const value = source[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
export function readClaudeInit(message: Record<string, unknown>): ClaudeInitObservation | null {
|
||||
const hookName = readClaudeFrameString(message, 'hook_name')
|
||||
const isInit = message.type === 'system' && message.subtype === 'init'
|
||||
const isSessionStart =
|
||||
message.type === 'system' &&
|
||||
(message.subtype === 'hook_started' || message.subtype === 'hook_response') &&
|
||||
hookName?.startsWith('SessionStart:') === true
|
||||
if (!isInit && !isSessionStart) {
|
||||
return null
|
||||
}
|
||||
const providerSessionId = readClaudeFrameString(message, 'session_id')
|
||||
return providerSessionId
|
||||
? {
|
||||
providerSessionId,
|
||||
uuid: isInit ? readClaudeFrameString(message, 'uuid') : null,
|
||||
model: isInit ? readClaudeFrameString(message, 'model') : null,
|
||||
message
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export function readClaudeModels(initialization: unknown): unknown[] {
|
||||
return isRecord(initialization) && Array.isArray(initialization.models)
|
||||
? initialization.models
|
||||
: []
|
||||
}
|
||||
|
||||
/** CLI capabilities advertised on the initialize result or the yielded system/init frame. */
|
||||
export function readClaudeCapabilities(
|
||||
init: ClaudeInitObservation,
|
||||
initialization: unknown
|
||||
): string[] {
|
||||
const fromResult = isRecord(initialization) ? initialization.capabilities : undefined
|
||||
const fromFrame = init.message.capabilities
|
||||
const source = Array.isArray(fromResult) ? fromResult : Array.isArray(fromFrame) ? fromFrame : []
|
||||
return source.filter((value): value is string => typeof value === 'string')
|
||||
}
|
||||
|
||||
export function claudeInitializationAuthError(
|
||||
initialization: unknown
|
||||
): AgentSessionAcquisitionRefusal | null {
|
||||
const account =
|
||||
isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null
|
||||
return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none'
|
||||
? new AgentSessionAcquisitionRefusal(
|
||||
'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.'
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
export function claudeAuthDiagnostic(
|
||||
init: ClaudeInitObservation,
|
||||
settings: unknown
|
||||
): ClaudeAuthDiagnostic {
|
||||
const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {}
|
||||
const apiKeySource = readClaudeFrameString(init.message, 'apiKeySource')
|
||||
const configured = (key: string): boolean =>
|
||||
(typeof env[key] === 'string' && (env[key] as string).trim().length > 0) ||
|
||||
Boolean(process.env[key]?.trim())
|
||||
return {
|
||||
apiKeySourceConfigured: apiKeySource !== null && apiKeySource !== 'none',
|
||||
baseUrlConfigured: configured('ANTHROPIC_BASE_URL'),
|
||||
authTokenConfigured: configured('ANTHROPIC_AUTH_TOKEN'),
|
||||
apiKeyConfigured: configured('ANTHROPIC_API_KEY'),
|
||||
settingSources: CLAUDE_DEFAULT_SETTING_SOURCES
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalMessageItem
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type { NativeChatBlock } from '../../shared/native-chat-types'
|
||||
import {
|
||||
boundInlineText,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from '../native-chat/agent-session-journal/journal-payload-bounds'
|
||||
|
||||
export type ClaudeMessageEnvelope = {
|
||||
sessionId: string
|
||||
uuid: string
|
||||
role: 'assistant' | 'user'
|
||||
content: unknown[]
|
||||
/** Messages API id shared by every frame of one streamed assistant message. */
|
||||
messageId: string | null
|
||||
parentToolUseId: string | null
|
||||
}
|
||||
|
||||
export type ClaudeToolUse = { id: string; name: string; input: unknown }
|
||||
export type ClaudeToolResult = { toolUseId: string; output: string; failed: boolean }
|
||||
|
||||
export function claudeRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
export function claudeText(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
export function readClaudeMessageEnvelope(
|
||||
frame: Record<string, unknown>
|
||||
): ClaudeMessageEnvelope | null {
|
||||
if (frame.type !== 'assistant' && frame.type !== 'user') {
|
||||
return null
|
||||
}
|
||||
const message = claudeRecord(frame.message)
|
||||
const sessionId = claudeText(frame.session_id)
|
||||
const uuid = claudeText(frame.uuid)
|
||||
const role = message?.role
|
||||
return sessionId && uuid && (role === 'assistant' || role === 'user')
|
||||
? {
|
||||
sessionId,
|
||||
uuid,
|
||||
role,
|
||||
content: messageContent(message?.content),
|
||||
messageId: claudeText(message?.id),
|
||||
parentToolUseId: claudeText(frame.parent_tool_use_id)
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
// A user replay may carry its text as a bare string (MessageParam), not blocks.
|
||||
function messageContent(content: unknown): unknown[] {
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
}
|
||||
const text = claudeText(content)
|
||||
return text ? [{ type: 'text', text }] : []
|
||||
}
|
||||
|
||||
export function claudeMessageIdentity(
|
||||
envelope: Pick<ClaudeMessageEnvelope, 'sessionId' | 'uuid'>
|
||||
): AgentJournalItemIdentity {
|
||||
return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid }
|
||||
}
|
||||
|
||||
function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] {
|
||||
const blocks: NativeChatBlock[] = []
|
||||
for (const value of envelope.content) {
|
||||
const part = claudeRecord(value)
|
||||
const text = claudeText(part?.text)
|
||||
if (part?.type === 'text' && text) {
|
||||
blocks.push({ type: 'text', text })
|
||||
continue
|
||||
}
|
||||
const source = claudeRecord(part?.source)
|
||||
const url = claudeText(source?.url)
|
||||
if (part?.type === 'image' && source?.type === 'url' && url) {
|
||||
blocks.push({ type: 'image-ref', url })
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournalMessageItem | null {
|
||||
const blocks = messageBlocks(envelope)
|
||||
return blocks.length > 0 ? { kind: 'message', role: envelope.role, blocks } : null
|
||||
}
|
||||
|
||||
export function claudeHasReplayContent(envelope: ClaudeMessageEnvelope): boolean {
|
||||
return envelope.content.some((value) => {
|
||||
const part = claudeRecord(value)
|
||||
return part !== null && part.type !== 'tool_result'
|
||||
})
|
||||
}
|
||||
|
||||
export function claudeToolUses(envelope: ClaudeMessageEnvelope): ClaudeToolUse[] {
|
||||
return envelope.content.flatMap((value) => {
|
||||
const part = claudeRecord(value)
|
||||
const id = claudeText(part?.id)
|
||||
const name = claudeText(part?.name)
|
||||
return part?.type === 'tool_use' && id && name ? [{ id, name, input: part.input ?? null }] : []
|
||||
})
|
||||
}
|
||||
|
||||
function resultText(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return value === undefined ? '' : JSON.stringify(value)
|
||||
}
|
||||
return value
|
||||
.flatMap((entry) => {
|
||||
if (typeof entry === 'string') {
|
||||
return [entry]
|
||||
}
|
||||
const part = claudeRecord(entry)
|
||||
return part?.type === 'text' && typeof part.text === 'string' ? [part.text] : []
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function claudeToolResults(envelope: ClaudeMessageEnvelope): ClaudeToolResult[] {
|
||||
return envelope.content.flatMap((value) => {
|
||||
const part = claudeRecord(value)
|
||||
const toolUseId = claudeText(part?.tool_use_id)
|
||||
return part?.type === 'tool_result' && toolUseId
|
||||
? [
|
||||
{
|
||||
toolUseId,
|
||||
output: resultText(part.content),
|
||||
failed: part.is_error === true
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
export function claudeThinkingText(envelope: ClaudeMessageEnvelope): string | null {
|
||||
const parts = envelope.content.flatMap((value) => {
|
||||
const part = claudeRecord(value)
|
||||
const thinking = claudeText(part?.thinking)
|
||||
return part?.type === 'thinking' && thinking ? [thinking] : []
|
||||
})
|
||||
return parts.length > 0 ? parts.join('\n') : null
|
||||
}
|
||||
|
||||
export function claudeToolBody(input: {
|
||||
tool: ClaudeToolUse
|
||||
result?: ClaudeToolResult
|
||||
}): AgentJournalItemBody {
|
||||
return {
|
||||
kind: 'tool-call',
|
||||
name: input.tool.name,
|
||||
input: input.tool.input,
|
||||
state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running',
|
||||
...(input.result
|
||||
? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function claudeStreamingMessageBody(text: string): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
export function claudeToolIdentity(sessionId: string, toolUseId: string): AgentJournalItemIdentity {
|
||||
return { provider: 'orca', clientMessageId: `claude-tool:${sessionId}:${toolUseId}` }
|
||||
}
|
||||
|
||||
export function claudeThinkingIdentity(sessionId: string, uuid: string): AgentJournalItemIdentity {
|
||||
return { provider: 'orca', clientMessageId: `claude-thinking:${sessionId}:${uuid}` }
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
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 {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalRenderItem,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../shared/structured-agent-session-projection'
|
||||
import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory'
|
||||
import {
|
||||
createDeferredStructuredAgentSessionEventSink,
|
||||
type StructuredAgentSessionEventSink
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import {
|
||||
boundInlineText,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from '../native-chat/agent-session-journal/journal-payload-bounds'
|
||||
import type { ClaudePendingPrompt } from './claude-structured-prompt-replies'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
|
||||
function sinkState() {
|
||||
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
|
||||
const tombstones: AgentJournalItemIdentity[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body) => items.push({ identity, body }),
|
||||
appendTombstone: (identity) => tombstones.push(identity),
|
||||
publish: vi.fn()
|
||||
}
|
||||
return { sink, items, tombstones }
|
||||
}
|
||||
|
||||
function message(
|
||||
type: 'assistant' | 'user',
|
||||
uuid: string,
|
||||
content: unknown[],
|
||||
parentToolUseId: string | null = null
|
||||
) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
...(type === 'user' && parentToolUseId === null ? { startsTurn: true as const } : {}),
|
||||
message: {
|
||||
type,
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
message: { role: type, content }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frames below follow the Claude Code 2.1.258 / SDK 0.3.251 partial-message
|
||||
// cadence captured from the real CLI: every stream_event carries its own uuid,
|
||||
// the final assistant frame for a block carries yet another, and only
|
||||
// message.id ties them together.
|
||||
function streamEvent(uuid: string, event: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'stream_event',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resultFrame(subtype: string, fields: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'result',
|
||||
subtype,
|
||||
duration_ms: 1200,
|
||||
duration_api_ms: 1100,
|
||||
num_turns: 1,
|
||||
session_id: 'claude-session',
|
||||
uuid: `result-${subtype}`,
|
||||
...fields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One streamed text turn in wire order: message_start, the block's start frame,
|
||||
* one delta per chunk, the block's final assistant frame, the stop frames and
|
||||
* the success result. */
|
||||
function streamedTextTurn(input: {
|
||||
messageId: string
|
||||
startUuid: string
|
||||
finalUuid: string
|
||||
chunks: string[]
|
||||
}) {
|
||||
const text = input.chunks.join('')
|
||||
return {
|
||||
start: [
|
||||
streamEvent(`${input.messageId}-message-start`, {
|
||||
type: 'message_start',
|
||||
message: { id: input.messageId, role: 'assistant', content: [] }
|
||||
}),
|
||||
streamEvent(input.startUuid, {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' }
|
||||
})
|
||||
],
|
||||
deltas: input.chunks.map((chunk, index) =>
|
||||
streamEvent(`${input.messageId}-delta-${index}`, {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: chunk }
|
||||
})
|
||||
),
|
||||
final: {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'assistant',
|
||||
uuid: input.finalUuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
id: input.messageId,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
stop_reason: null
|
||||
}
|
||||
}
|
||||
},
|
||||
stop: [
|
||||
streamEvent(`${input.messageId}-block-stop`, { type: 'content_block_stop', index: 0 }),
|
||||
streamEvent(`${input.messageId}-message-delta`, {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn' }
|
||||
}),
|
||||
streamEvent(`${input.messageId}-message-stop`, { type: 'message_stop' }),
|
||||
resultFrame('success', {
|
||||
is_error: false,
|
||||
result: text,
|
||||
stop_reason: 'end_turn',
|
||||
terminal_reason: 'completed'
|
||||
})
|
||||
],
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessages<T extends { body: AgentJournalItemBody }>(items: T[]): T[] {
|
||||
return items.filter((item) => item.body.kind === 'message' && item.body.role === 'assistant')
|
||||
}
|
||||
|
||||
function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] {
|
||||
return items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : []
|
||||
)
|
||||
}
|
||||
|
||||
const JOURNAL_IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'leaf-1' }
|
||||
}
|
||||
|
||||
let journalRoot = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
journalRoot = await mkdtemp(join(tmpdir(), 'orca-claude-journal-translation-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(journalRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Claude structured journal translation', () => {
|
||||
it('coalesces partial deltas onto the block identity and reconciles the final frame onto it', () => {
|
||||
const state = sinkState()
|
||||
let scheduled: (() => void) | null = null
|
||||
const translator = createClaudeJournalTranslator({
|
||||
sink: state.sink,
|
||||
schedule: (run, delay) => {
|
||||
expect(delay).toBe(60)
|
||||
scheduled = run
|
||||
return () => {
|
||||
scheduled = null
|
||||
}
|
||||
}
|
||||
})
|
||||
const turn = streamedTextTurn({
|
||||
messageId: 'msg_01',
|
||||
startUuid: 'block-start-1',
|
||||
finalUuid: 'assistant-final-1',
|
||||
chunks: ['ST', 'REAMOK_ELEC_64E632']
|
||||
})
|
||||
const streamedIdentity = {
|
||||
provider: 'claude',
|
||||
sessionId: 'claude-session',
|
||||
uuid: 'block-start-1'
|
||||
}
|
||||
|
||||
for (const event of turn.start) {
|
||||
translator.handle(event)
|
||||
}
|
||||
for (const delta of turn.deltas) {
|
||||
translator.handle(delta)
|
||||
}
|
||||
expect(state.items).toEqual([])
|
||||
|
||||
const run = scheduled as (() => void) | null
|
||||
run?.()
|
||||
expect(state.items.at(-1)).toEqual({
|
||||
identity: streamedIdentity,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: turn.text }] }
|
||||
})
|
||||
|
||||
translator.handle(turn.final)
|
||||
for (const event of turn.stop) {
|
||||
translator.handle(event)
|
||||
}
|
||||
const assistant = assistantMessages(state.items)
|
||||
expect(assistant.at(-1)).toEqual({
|
||||
identity: streamedIdentity,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: turn.text }] }
|
||||
})
|
||||
expect(new Set(assistant.map((item) => agentJournalItemKey(item.identity))).size).toBe(1)
|
||||
expect(providerFrameKinds(state.items)).toEqual([])
|
||||
})
|
||||
|
||||
it('journals a count-to-200 stream as one assistant item carrying the complete reply', async () => {
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: JOURNAL_IDENTITY,
|
||||
journalDir: journalRoot,
|
||||
now: () => 1_700_000_000_000,
|
||||
mintEpoch: () => 'epoch-1'
|
||||
})
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink()
|
||||
deferred.bind({ journal, fence: 1, publish: vi.fn() })
|
||||
let scheduled: (() => void) | null = null
|
||||
const translator = createClaudeJournalTranslator({
|
||||
sink: deferred.sink,
|
||||
schedule: (run) => {
|
||||
scheduled = run
|
||||
return () => {
|
||||
scheduled = null
|
||||
}
|
||||
}
|
||||
})
|
||||
const numbers = Array.from({ length: 200 }, (_, index) => String(index + 1))
|
||||
// The chunk boundaries the real CLI produced for this prompt.
|
||||
const boundaries = [0, 1, 45, 93, 141, 189, 200]
|
||||
const chunks = boundaries.slice(1).map((end, index) => {
|
||||
const slice = numbers.slice(boundaries[index], end).join('\n')
|
||||
return index === 0 ? slice : `\n${slice}`
|
||||
})
|
||||
const turn = streamedTextTurn({
|
||||
messageId: 'msg_count',
|
||||
startUuid: 'count-start',
|
||||
finalUuid: 'count-final',
|
||||
chunks
|
||||
})
|
||||
|
||||
for (const event of turn.start) {
|
||||
translator.handle(event)
|
||||
}
|
||||
for (const delta of turn.deltas) {
|
||||
translator.handle(delta)
|
||||
// Each chunk lands in its own coalescing window, as it did on the wire.
|
||||
const run = scheduled as (() => void) | null
|
||||
run?.()
|
||||
}
|
||||
translator.handle(turn.final)
|
||||
for (const event of turn.stop) {
|
||||
translator.handle(event)
|
||||
}
|
||||
await deferred.drained()
|
||||
|
||||
const items: AgentJournalRenderItem[] = journal.snapshot().items
|
||||
const assistant = assistantMessages(items)
|
||||
expect(assistant.map((item) => item.itemId)).toEqual(['claude:claude-session:count-start'])
|
||||
expect(assistant[0]?.body).toEqual({
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: numbers.join('\n') }]
|
||||
})
|
||||
expect(providerFrameKinds(items)).toEqual([])
|
||||
})
|
||||
|
||||
it('settles result frames, empty thinking and string user replays without painting a row', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
startsTurn: true,
|
||||
message: {
|
||||
type: 'user',
|
||||
uuid: 'user-replay-1',
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
isReplay: true,
|
||||
timestamp: '2026-09-01T00:00:00.000Z',
|
||||
message: { role: 'user', content: 'Reply with exactly PROBE_OK_1 and nothing else.' }
|
||||
}
|
||||
})
|
||||
translator.handle(
|
||||
message('assistant', 'assistant-thinking-empty', [
|
||||
{ type: 'thinking', thinking: '', signature: 'CAQS6QcKEAgRGAI4AUIIdGhpbmtpbmc' }
|
||||
])
|
||||
)
|
||||
translator.handle(
|
||||
resultFrame('success', {
|
||||
is_error: false,
|
||||
result: 'PROBE_OK_1',
|
||||
stop_reason: 'end_turn',
|
||||
terminal_reason: 'completed'
|
||||
})
|
||||
)
|
||||
translator.handle(
|
||||
message('user', 'user-interrupt', [{ type: 'text', text: '[Request interrupted by user]' }])
|
||||
)
|
||||
translator.handle(
|
||||
resultFrame('error_during_execution', {
|
||||
is_error: true,
|
||||
errors: ['[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null'],
|
||||
stop_reason: null,
|
||||
terminal_reason: 'aborted_streaming',
|
||||
permission_denials: []
|
||||
})
|
||||
)
|
||||
translator.handle(message('user', 'control-only', []))
|
||||
|
||||
expect(providerFrameKinds(state.items)).toEqual([])
|
||||
expect(
|
||||
state.items.flatMap((item) =>
|
||||
item.body.kind === 'message' && item.body.role === 'user' ? [item.body.blocks] : []
|
||||
)
|
||||
).toEqual([
|
||||
[{ type: 'text', text: 'Reply with exactly PROBE_OK_1 and nothing else.' }],
|
||||
[{ type: 'text', text: '[Request interrupted by user]' }]
|
||||
])
|
||||
expect(
|
||||
state.items.some((item) => item.body.kind === 'status' && !item.body.turnLifecycle)
|
||||
).toBe(false)
|
||||
expect(
|
||||
state.tombstones.flatMap((identity) =>
|
||||
identity.provider === 'legacy' ? [identity.recordId] : []
|
||||
)
|
||||
).toEqual(['turn-lifecycle:user-replay-1', 'turn-lifecycle:user-interrupt'])
|
||||
})
|
||||
|
||||
it('does not reopen a completed turn when the SDK replays its user row after restart', () => {
|
||||
const live = sinkState()
|
||||
const liveTranslator = createClaudeJournalTranslator({ sink: live.sink })
|
||||
const replay = {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'user',
|
||||
uuid: 'picker-command-1',
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
isReplay: true,
|
||||
message: { role: 'user', content: '/model' }
|
||||
}
|
||||
}
|
||||
|
||||
liveTranslator.handle({ ...replay, startsTurn: true })
|
||||
liveTranslator.handle(resultFrame('success', { is_error: false, result: '' }))
|
||||
expect(live.tombstones).toContainEqual({
|
||||
provider: 'legacy',
|
||||
agent: 'claude',
|
||||
sessionId: 'claude-session',
|
||||
recordId: 'turn-lifecycle:picker-command-1'
|
||||
})
|
||||
liveTranslator.dispose()
|
||||
|
||||
const restarted = sinkState()
|
||||
const restartedTranslator = createClaudeJournalTranslator({ sink: restarted.sink })
|
||||
restartedTranslator.handle(replay)
|
||||
|
||||
expect(
|
||||
activeStructuredAgentSessionTurnId(
|
||||
restarted.items.map((item, sequence) => ({
|
||||
itemId: agentJournalItemKey(item.identity),
|
||||
revision: 1,
|
||||
body: item.body,
|
||||
sequence,
|
||||
observedAt: sequence
|
||||
}))
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces an API error carried by a success-subtype result with no assistant frame', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(message('user', 'user-1', [{ type: 'text', text: 'summarize this' }]))
|
||||
// The SDK models this as a SUCCESS-subtype result whose `result` string is the
|
||||
// user-facing API error. Suppressing it as ordinary turn bookkeeping ends the
|
||||
// turn with nothing shown at all.
|
||||
translator.handle(
|
||||
resultFrame('success', {
|
||||
is_error: true,
|
||||
result: 'API Error: 529 upstream overloaded',
|
||||
stop_reason: null,
|
||||
terminal_reason: 'api_error'
|
||||
})
|
||||
)
|
||||
|
||||
expect(providerFrameKinds(state.items)).toEqual(['message:result:success'])
|
||||
expect(state.items.at(-1)?.body).toMatchObject({
|
||||
kind: 'status',
|
||||
text: 'API Error: 529 upstream overloaded'
|
||||
})
|
||||
// The turn still settles: the error is an extra row, not a stuck lifecycle.
|
||||
expect(
|
||||
state.tombstones.flatMap((identity) =>
|
||||
identity.provider === 'legacy' ? [identity.recordId] : []
|
||||
)
|
||||
).toEqual(['turn-lifecycle:user-1'])
|
||||
})
|
||||
|
||||
it('drops the stream state of turns that ended without their final frame', () => {
|
||||
const state = sinkState()
|
||||
let scheduled: (() => void) | null = null
|
||||
const translator = createClaudeJournalTranslator({
|
||||
sink: state.sink,
|
||||
schedule: (run) => {
|
||||
scheduled = run
|
||||
return () => {
|
||||
scheduled = null
|
||||
}
|
||||
}
|
||||
})
|
||||
for (let turn = 0; turn < 3; turn += 1) {
|
||||
const aborted = streamedTextTurn({
|
||||
messageId: `msg_abort_${turn}`,
|
||||
startUuid: `abort-start-${turn}`,
|
||||
finalUuid: `abort-final-${turn}`,
|
||||
chunks: ['x'.repeat(4_000)]
|
||||
})
|
||||
for (const event of [...aborted.start, ...aborted.deltas]) {
|
||||
translator.handle(event)
|
||||
}
|
||||
const run = scheduled as (() => void) | null
|
||||
run?.()
|
||||
// The user interrupts: the result arrives with no final assistant frame,
|
||||
// so nothing ever reconciles these blocks.
|
||||
translator.handle(
|
||||
resultFrame('error_during_execution', {
|
||||
is_error: true,
|
||||
terminal_reason: 'aborted_streaming'
|
||||
})
|
||||
)
|
||||
// The partial text is already journaled; only the live state is dropped.
|
||||
expect(translator.pendingStreamedBlocks).toBe(0)
|
||||
}
|
||||
|
||||
expect(assistantMessages(state.items)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps an ordinary successful result off the timeline', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(resultFrame('success', { is_error: false, result: 'done', errors: [] }))
|
||||
|
||||
expect(providerFrameKinds(state.items)).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces the reason an error-subtype result stopped the turn', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(
|
||||
resultFrame('error_max_turns', { is_error: true, errors: ['turn limit reached'] })
|
||||
)
|
||||
|
||||
expect(providerFrameKinds(state.items)).toEqual(['message:result:error_max_turns'])
|
||||
})
|
||||
|
||||
it('keeps an unmodeled result subtype on the bounded provider fallback', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(
|
||||
resultFrame('error_from_the_future', { is_error: true, errors: ['budget exhausted'] })
|
||||
)
|
||||
|
||||
expect(providerFrameKinds(state.items)).toEqual(['message:result:error_from_the_future'])
|
||||
})
|
||||
|
||||
it('journals turn lifecycle and updates one tool row through its result', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(message('user', 'user-1', [{ type: 'text', text: 'List files' }]))
|
||||
translator.handle(
|
||||
message('assistant', 'assistant-tool', [
|
||||
{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls' } }
|
||||
])
|
||||
)
|
||||
translator.handle(
|
||||
message(
|
||||
'user',
|
||||
'tool-result-1',
|
||||
[{ type: 'tool_result', tool_use_id: 'tool-1', content: 'a.ts\nb.ts' }],
|
||||
'tool-1'
|
||||
)
|
||||
)
|
||||
|
||||
const keyed = new Map(
|
||||
state.items.map((item) => [agentJournalItemKey(item.identity), item.body])
|
||||
)
|
||||
expect(keyed.get('claude:claude-session:user-1')).toMatchObject({
|
||||
kind: 'message',
|
||||
role: 'user'
|
||||
})
|
||||
expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
output: { head: 'a.ts\nb.ts', truncated: false }
|
||||
})
|
||||
expect(
|
||||
state.items.some(
|
||||
(item) => item.body.kind === 'status' && item.body.turnLifecycle?.turnId === 'user-1'
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
translator.handle(
|
||||
message(
|
||||
'user',
|
||||
'tool-result-2',
|
||||
[{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done again' }],
|
||||
'tool-1'
|
||||
)
|
||||
)
|
||||
expect(state.items.at(-1)?.body).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
name: 'tool',
|
||||
input: null,
|
||||
output: { head: 'done again' }
|
||||
})
|
||||
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'result', session_id: 'claude-session', uuid: 'result-1' }
|
||||
})
|
||||
expect(state.tombstones.at(-1)).toMatchObject({
|
||||
provider: 'legacy',
|
||||
agent: 'claude',
|
||||
recordId: 'turn-lifecycle:user-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds persisted thinking text to the shared journal payload limit', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
const thinking = 'considering '.repeat(20_000)
|
||||
|
||||
translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }]))
|
||||
|
||||
expect(state.items.at(-1)?.body).toEqual({
|
||||
kind: 'status',
|
||||
text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a cancellable lifecycle for image-only root user replays', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(
|
||||
message('user', 'user-image', [
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } }
|
||||
])
|
||||
)
|
||||
|
||||
expect(state.items.at(-1)?.body).toEqual({
|
||||
kind: 'status',
|
||||
text: 'Claude is working…',
|
||||
turnLifecycle: { turnId: 'user-image', state: 'running' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not start a lifecycle for a top-level user tool result', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(
|
||||
message('user', 'tool-result-only', [
|
||||
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(state.items.map((item) => agentJournalItemKey(item.identity))).toEqual([
|
||||
'orca:claude-tool%3Aclaude-session%3Atool-1'
|
||||
])
|
||||
expect(state.items[0]?.body).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
state: 'completed',
|
||||
output: { head: 'done' }
|
||||
})
|
||||
expect(
|
||||
state.items.some(
|
||||
(item) => item.body.kind === 'status' && item.body.turnLifecycle !== undefined
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('paints nothing for a user frame that carries no content', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(message('user', 'control-only', []))
|
||||
|
||||
expect(state.items).toEqual([])
|
||||
expect(state.tombstones).toEqual([])
|
||||
})
|
||||
|
||||
it('renders unmodeled substantive Claude frames as bounded provider rows', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'system', subtype: 'local_command_output', summary: 'x'.repeat(100_000) }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'system', subtype: 'hook_response', hook_name: 'PostToolUse' }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'system', subtype: 'command_started', command: '/compact' }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'result', usage: { input_tokens: 12 }, total_cost_usd: 0.01 }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'tool_progress', tool_use_id: 'tool-1', elapsed_time_seconds: 2 }
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'prompt_suggestion', suggestion: '/compact' }
|
||||
})
|
||||
translator.handle(
|
||||
message('user', 'attachment-1', [
|
||||
{ type: 'document', source: { type: 'base64', media_type: 'application/pdf' } }
|
||||
])
|
||||
)
|
||||
translator.handle({
|
||||
type: 'provider-frame',
|
||||
sessionId: 'orca-session',
|
||||
kind: 'control_request:future_control',
|
||||
payload: { subtype: 'future_control' }
|
||||
})
|
||||
|
||||
const frames = state.items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame] : []
|
||||
)
|
||||
expect(frames.map((frame) => frame.kind)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'message:system:local_command_output',
|
||||
'message:system:command_started',
|
||||
'message:result',
|
||||
'message:user:content:document',
|
||||
'control_request:future_control'
|
||||
])
|
||||
)
|
||||
expect(frames.map((frame) => frame.kind)).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
'message:system:hook_response',
|
||||
'message:tool_progress',
|
||||
'message:prompt_suggestion'
|
||||
])
|
||||
)
|
||||
expect(
|
||||
frames.find((frame) => frame.kind === 'message:system:local_command_output')?.payload
|
||||
).toEqual(expect.objectContaining({ truncated: true, byteLength: expect.any(Number) }))
|
||||
})
|
||||
|
||||
it('preserves a question group as one addressable prompt and cancels it durably', () => {
|
||||
const state = sinkState()
|
||||
const bindings: unknown[][] = []
|
||||
const translator = createClaudeJournalTranslator({
|
||||
sink: state.sink,
|
||||
bindPromptItemId: (...args) => bindings.push(args)
|
||||
})
|
||||
const approval = prompt({
|
||||
requestId: 'permission-1',
|
||||
promptKey: 'permission-1',
|
||||
toolUseId: 'tool-1',
|
||||
toolName: 'Bash',
|
||||
kind: 'approval',
|
||||
input: { command: 'git status' },
|
||||
questionIds: []
|
||||
})
|
||||
translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: approval })
|
||||
expect(state.items.at(-1)?.body).toMatchObject({
|
||||
kind: 'approval',
|
||||
title: 'Allow Bash?',
|
||||
options: expect.arrayContaining([{ id: 'allow', label: 'Allow' }])
|
||||
})
|
||||
expect(bindings[0]).toEqual([
|
||||
'orca:claude-prompt%3Aorca-session%3Apermission-1',
|
||||
'permission-1'
|
||||
])
|
||||
|
||||
const questions = prompt({
|
||||
requestId: 'questions-1',
|
||||
promptKey: 'questions-1',
|
||||
toolUseId: 'tool-q',
|
||||
toolName: 'AskUserQuestion',
|
||||
kind: 'question',
|
||||
input: {
|
||||
questions: [
|
||||
{ question: 'Library?', options: [{ label: 'Luxon' }] },
|
||||
{ question: 'Ship?', options: [{ label: 'Yes' }] }
|
||||
]
|
||||
},
|
||||
questionIds: ['Library?', 'Ship?']
|
||||
})
|
||||
translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: questions })
|
||||
expect(state.items.filter((item) => item.body.kind === 'question')).toHaveLength(1)
|
||||
expect(state.items.at(-1)?.body).toMatchObject({
|
||||
kind: 'question',
|
||||
questions: [
|
||||
{ id: 'q1', question: 'Library?', multiSelect: false },
|
||||
{ id: 'q2', question: 'Ship?', multiSelect: false }
|
||||
]
|
||||
})
|
||||
expect(bindings.at(-1)).toEqual([
|
||||
'orca:claude-prompt%3Aorca-session%3Aquestions-1',
|
||||
'questions-1'
|
||||
])
|
||||
|
||||
const multiSelect = prompt({
|
||||
requestId: 'questions-multi',
|
||||
promptKey: 'questions-multi',
|
||||
toolUseId: 'tool-multi',
|
||||
toolName: 'AskUserQuestion',
|
||||
kind: 'question',
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
question: 'Libraries?',
|
||||
multiSelect: true,
|
||||
options: [{ label: 'Luxon' }, { label: 'Temporal' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
questionIds: ['Libraries?']
|
||||
})
|
||||
translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: multiSelect })
|
||||
expect(state.items.at(-1)?.body).toMatchObject({
|
||||
kind: 'question',
|
||||
question: '1 grouped question from Claude',
|
||||
options: [],
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Libraries?',
|
||||
multiSelect: true,
|
||||
options: [{ label: 'Luxon' }, { label: 'Temporal' }],
|
||||
freeTextQuestionId: 'q1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
translator.handle({
|
||||
type: 'prompt-cancelled',
|
||||
sessionId: 'orca-session',
|
||||
promptKey: 'questions-1'
|
||||
})
|
||||
expect(state.tombstones).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
function prompt(
|
||||
input: Pick<
|
||||
ClaudePendingPrompt,
|
||||
'requestId' | 'promptKey' | 'toolUseId' | 'toolName' | 'kind' | 'input' | 'questionIds'
|
||||
>
|
||||
): ClaudePendingPrompt {
|
||||
return {
|
||||
...input,
|
||||
suggestions: [],
|
||||
answers: new Map(),
|
||||
settle: () => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import {
|
||||
boundInlineText,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from '../native-chat/agent-session-journal/journal-payload-bounds'
|
||||
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
|
||||
import {
|
||||
claudeMessageBody,
|
||||
claudeMessageIdentity,
|
||||
claudeHasReplayContent,
|
||||
claudeRecord,
|
||||
claudeStreamingMessageBody,
|
||||
claudeText,
|
||||
claudeThinkingIdentity,
|
||||
claudeThinkingText,
|
||||
claudeToolBody,
|
||||
claudeToolIdentity,
|
||||
claudeToolResults,
|
||||
claudeToolUses,
|
||||
readClaudeMessageEnvelope,
|
||||
type ClaudeToolUse
|
||||
} from './claude-structured-item-translation'
|
||||
import {
|
||||
claudeApprovalItem,
|
||||
claudePromptIdentity,
|
||||
claudeQuestionItems
|
||||
} from './claude-structured-prompt-items'
|
||||
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame'
|
||||
import {
|
||||
CLAUDE_UNRENDERABLE_CONTENT_TEXT,
|
||||
claudeProviderFrameKind,
|
||||
claudeResultFailure,
|
||||
createClaudeProviderFrameFallback,
|
||||
isModeledClaudeContent,
|
||||
isSettledClaudeResultKind
|
||||
} from './claude-structured-provider-fallback'
|
||||
import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity'
|
||||
import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
|
||||
|
||||
export type ClaudeJournalTranslatorDeps = {
|
||||
sink: StructuredAgentSessionEventSink
|
||||
bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void
|
||||
coalesceMs?: number
|
||||
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
|
||||
fallbackIdPrefix?: string
|
||||
}
|
||||
|
||||
export type ClaudeJournalTranslator = {
|
||||
handle: (event: ClaudeStructuredSessionEvent) => void
|
||||
flush: () => void
|
||||
/** Streamed blocks still awaiting a final frame. A settled turn leaves none. */
|
||||
readonly pendingStreamedBlocks: number
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function createClaudeSessionJournalTranslator(
|
||||
sink: StructuredAgentSessionEventSink | undefined,
|
||||
prompts: ClaudePromptRegistry,
|
||||
fallbackIdPrefix: string
|
||||
): ClaudeJournalTranslator | null {
|
||||
return sink
|
||||
? createClaudeJournalTranslator({
|
||||
sink,
|
||||
fallbackIdPrefix,
|
||||
bindPromptItemId: (itemId, promptKey, questionId) =>
|
||||
prompts.bindJournalItemId(itemId, promptKey, questionId)
|
||||
})
|
||||
: null
|
||||
}
|
||||
|
||||
function lifecycleIdentity(sessionId: string, turnId: string): AgentJournalItemIdentity {
|
||||
return {
|
||||
provider: 'legacy',
|
||||
agent: 'claude',
|
||||
sessionId,
|
||||
recordId: `turn-lifecycle:${turnId}`
|
||||
}
|
||||
}
|
||||
|
||||
export function createClaudeJournalTranslator(
|
||||
deps: ClaudeJournalTranslatorDeps
|
||||
): ClaudeJournalTranslator {
|
||||
const tools = new Map<string, ClaudeToolUse>()
|
||||
const promptItems = new Map<string, AgentJournalItemIdentity[]>()
|
||||
const streamedBlocks = createClaudeStreamedBlockRegistry()
|
||||
let currentTurn: { sessionId: string; turnId: string } | null = null
|
||||
const providerFallback = createClaudeProviderFrameFallback(
|
||||
deps.sink,
|
||||
deps.fallbackIdPrefix ?? 'acquisition'
|
||||
)
|
||||
const streamedText = createClaudeStreamedTextCheckpoints({
|
||||
...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }),
|
||||
...(deps.schedule ? { schedule: deps.schedule } : {}),
|
||||
persist: (identity, text) => {
|
||||
deps.sink.appendItem(identity, claudeStreamingMessageBody(text))
|
||||
deps.sink.publish()
|
||||
}
|
||||
})
|
||||
|
||||
const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => {
|
||||
const identity = lifecycleIdentity(sessionId, turnId)
|
||||
if (running) {
|
||||
deps.sink.appendItem(identity, {
|
||||
kind: 'status',
|
||||
text: 'Claude is working…',
|
||||
turnLifecycle: { turnId, state: 'running' }
|
||||
})
|
||||
} else {
|
||||
deps.sink.appendTombstone(identity)
|
||||
}
|
||||
deps.sink.publish()
|
||||
}
|
||||
|
||||
const handleStream = (message: Record<string, unknown>): boolean => {
|
||||
const delta = streamedBlocks.observe(message)
|
||||
if (!delta) {
|
||||
return false
|
||||
}
|
||||
streamedText.append(delta.identity, delta.text)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleMessage = (message: Record<string, unknown>, startsTurn: boolean): boolean => {
|
||||
const envelope = readClaudeMessageEnvelope(message)
|
||||
if (!envelope) {
|
||||
return false
|
||||
}
|
||||
let changed = false
|
||||
const body = claudeMessageBody(envelope)
|
||||
// The final frame of a streamed block lands on the block's identity, not its own uuid.
|
||||
const identity =
|
||||
(body && envelope.role === 'assistant' ? streamedBlocks.reconcile(envelope) : null) ??
|
||||
claudeMessageIdentity(envelope)
|
||||
streamedText.forget(agentJournalItemKey(identity))
|
||||
if (body) {
|
||||
deps.sink.appendItem(identity, body)
|
||||
changed = true
|
||||
}
|
||||
for (const tool of claudeToolUses(envelope)) {
|
||||
tools.set(tool.id, tool)
|
||||
deps.sink.appendItem(
|
||||
claudeToolIdentity(envelope.sessionId, tool.id),
|
||||
claudeToolBody({ tool })
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
for (const result of claudeToolResults(envelope)) {
|
||||
const tool = tools.get(result.toolUseId) ?? {
|
||||
id: result.toolUseId,
|
||||
name: 'tool',
|
||||
input: null
|
||||
}
|
||||
deps.sink.appendItem(
|
||||
claudeToolIdentity(envelope.sessionId, result.toolUseId),
|
||||
claudeToolBody({ tool, result })
|
||||
)
|
||||
// Tool inputs are only needed until their matching result arrives.
|
||||
tools.delete(result.toolUseId)
|
||||
changed = true
|
||||
}
|
||||
const thinking = claudeThinkingText(envelope)
|
||||
if (thinking) {
|
||||
deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), {
|
||||
kind: 'status',
|
||||
text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part))
|
||||
for (const part of unhandledContent) {
|
||||
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
|
||||
providerFallback.append(
|
||||
`message:${envelope.role}:content:${partType}`,
|
||||
part,
|
||||
readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
// An empty user frame is a replay with nothing to show, not an unknown kind.
|
||||
if (envelope.content.length === 0 && envelope.role === 'assistant') {
|
||||
providerFallback.append(`message:${envelope.role}:empty`, message)
|
||||
changed = true
|
||||
}
|
||||
if (
|
||||
envelope.role === 'user' &&
|
||||
startsTurn &&
|
||||
claudeHasReplayContent(envelope) &&
|
||||
message.parent_tool_use_id === null
|
||||
) {
|
||||
if (currentTurn) {
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
}
|
||||
currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid }
|
||||
publishLifecycle(envelope.sessionId, envelope.uuid, true)
|
||||
}
|
||||
if (changed) {
|
||||
deps.sink.publish()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handlePrompt = (event: Extract<ClaudeStructuredSessionEvent, { type: 'prompt' }>): void => {
|
||||
const identities: AgentJournalItemIdentity[] = []
|
||||
if (event.prompt.kind === 'question') {
|
||||
for (const question of claudeQuestionItems({
|
||||
sessionId: event.sessionId,
|
||||
prompt: event.prompt
|
||||
})) {
|
||||
identities.push(question.identity)
|
||||
deps.sink.appendItem(question.identity, question.body)
|
||||
deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey)
|
||||
}
|
||||
} else {
|
||||
const identity = claudePromptIdentity({
|
||||
sessionId: event.sessionId,
|
||||
promptKey: event.prompt.promptKey
|
||||
})
|
||||
identities.push(identity)
|
||||
deps.sink.appendItem(identity, claudeApprovalItem(event.prompt))
|
||||
deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey)
|
||||
}
|
||||
promptItems.set(event.prompt.promptKey, identities)
|
||||
deps.sink.publish()
|
||||
}
|
||||
|
||||
return {
|
||||
handle: (event) => {
|
||||
if (event.type === 'ended') {
|
||||
streamedText.flush()
|
||||
if (currentTurn) {
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
currentTurn = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'message' && handleStream(event.message)) {
|
||||
return
|
||||
}
|
||||
streamedText.flush()
|
||||
if (event.type === 'prompt') {
|
||||
handlePrompt(event)
|
||||
} else if (event.type === 'prompt-cancelled') {
|
||||
for (const identity of promptItems.get(event.promptKey) ?? []) {
|
||||
deps.sink.appendTombstone(identity)
|
||||
}
|
||||
promptItems.delete(event.promptKey)
|
||||
deps.sink.publish()
|
||||
} else if (event.type === 'message' && event.message.type === 'result') {
|
||||
if (currentTurn) {
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
currentTurn = null
|
||||
}
|
||||
// The turn is over. A block still awaiting its final keeps the text the
|
||||
// flush above journaled, but its live state goes: an interrupted turn
|
||||
// would otherwise retain that text for the life of the session.
|
||||
streamedBlocks.clear()
|
||||
streamedText.settle()
|
||||
const kind = claudeProviderFrameKind(event.message)
|
||||
// Ordinary turn bookkeeping stays suppressed; a reported failure never does.
|
||||
const failure = claudeResultFailure(event.message)
|
||||
if (failure || !isSettledClaudeResultKind(kind)) {
|
||||
providerFallback.append(kind, event.message, failure?.text)
|
||||
}
|
||||
} else if (event.type === 'message') {
|
||||
if (!handleMessage(event.message, event.startsTurn === true)) {
|
||||
providerFallback.append(claudeProviderFrameKind(event.message), event.message)
|
||||
}
|
||||
} else if (event.type === 'provider-frame') {
|
||||
providerFallback.append(event.kind, event.payload)
|
||||
}
|
||||
},
|
||||
flush: streamedText.flush,
|
||||
get pendingStreamedBlocks() {
|
||||
return streamedText.pending
|
||||
},
|
||||
dispose: () => {
|
||||
streamedText.dispose()
|
||||
tools.clear()
|
||||
promptItems.clear()
|
||||
streamedBlocks.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
||||
import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy'
|
||||
import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support'
|
||||
import {
|
||||
CLAUDE_DEFAULT_SETTING_SOURCES,
|
||||
CLAUDE_STRUCTURED_BASE_OPTIONS,
|
||||
claudeSdkOptionsForLaunchArgs,
|
||||
claudeSessionIdForOrcaSession,
|
||||
createClaudeStructuredLaunchResolver
|
||||
} from './claude-structured-launch-resolution'
|
||||
|
||||
const SESSION_ID = 'orca-session-1'
|
||||
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
|
||||
ReturnType<typeof createClaudeStructuredLaunchResolver>
|
||||
>[0]['identity']
|
||||
|
||||
function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord {
|
||||
return {
|
||||
sessionId: SESSION_ID,
|
||||
provider: 'claude',
|
||||
location: {
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' },
|
||||
providerHandleChain: [],
|
||||
...overrides
|
||||
} as AgentSessionRecord
|
||||
}
|
||||
|
||||
function identityAt(leafUuid: string | null): typeof IDENTITY {
|
||||
return {
|
||||
...IDENTITY,
|
||||
providerHandle: { kind: 'claude', sessionId: 'provider-current', leafUuid }
|
||||
}
|
||||
}
|
||||
|
||||
function makeExecutable(path: string): void {
|
||||
mkdirSync(join(path, '..'), { recursive: true })
|
||||
writeFileSync(path, '')
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
function resolverFor(
|
||||
value: AgentSessionRecord | null,
|
||||
resolveEnv?: () => Record<string, string>,
|
||||
stripAuthEnv = false
|
||||
) {
|
||||
return createClaudeStructuredLaunchResolver({
|
||||
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async (id) => `/repos/${id}`,
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv }),
|
||||
...(resolveEnv ? { resolveEnv } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') {
|
||||
return {
|
||||
id,
|
||||
email: `${id}@example.com`,
|
||||
managedAuthPath: `/managed/${id}`,
|
||||
managedAuthRuntime,
|
||||
authMethod: 'subscription-oauth' as const,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
lastAuthenticatedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_SELECTED: ClaudeManagedAccountGateSettings = {
|
||||
claudeManagedAccounts: [managedAccount('host-1', 'host')],
|
||||
activeClaudeManagedAccountId: 'host-1',
|
||||
activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} }
|
||||
}
|
||||
|
||||
/** The normalized steady state of a Windows user whose only Claude account is WSL-managed: the
|
||||
* prune drops the WSL account out of the host slot and persists that. */
|
||||
const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = {
|
||||
claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')],
|
||||
activeClaudeManagedAccountId: null,
|
||||
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } }
|
||||
}
|
||||
|
||||
const RESUMABLE = record({
|
||||
providerHandleChain: [
|
||||
{ handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: 'leaf-current' } }
|
||||
] as AgentSessionRecord['providerHandleChain']
|
||||
})
|
||||
|
||||
describe('claude structured launch resolution', () => {
|
||||
it('pre-mints a stable provider id and pins interactive setting sources', async () => {
|
||||
const first = await resolverFor(record())({ identity: IDENTITY })
|
||||
const second = await resolverFor(record())({ identity: IDENTITY })
|
||||
|
||||
expect(first.providerSessionId).toBe(claudeSessionIdForOrcaSession(SESSION_ID))
|
||||
expect(second.providerSessionId).toBe(first.providerSessionId)
|
||||
expect(first).toMatchObject({
|
||||
pathToClaudeCodeExecutable: '/usr/local/bin/claude',
|
||||
cwd: '/repos/workspace-1',
|
||||
claudeConfigDir: '/home/work/.claude',
|
||||
resumeLeafUuid: null,
|
||||
resumed: false
|
||||
})
|
||||
expect(first.options).toEqual({
|
||||
includePartialMessages: true,
|
||||
settingSources: [...CLAUDE_DEFAULT_SETTING_SOURCES],
|
||||
supportedDialogKinds: [],
|
||||
extraArgs: { 'replay-user-messages': null },
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
sessionId: first.providerSessionId
|
||||
})
|
||||
expect(first.options.resume).toBeUndefined()
|
||||
expect(CLAUDE_STRUCTURED_BASE_OPTIONS.includePartialMessages).toBe(true)
|
||||
})
|
||||
|
||||
it('resumes the session and leaf at the durable chain head', async () => {
|
||||
const launch = await resolverFor(
|
||||
record({
|
||||
providerHandleChain: [
|
||||
{ handle: { provider: 'claude', sessionId: 'provider-old', leafUuid: 'leaf-old' } },
|
||||
{
|
||||
handle: {
|
||||
provider: 'claude',
|
||||
sessionId: 'provider-current',
|
||||
leafUuid: 'leaf-current'
|
||||
}
|
||||
}
|
||||
] as AgentSessionRecord['providerHandleChain']
|
||||
})
|
||||
)({ identity: identityAt('leaf-current') })
|
||||
|
||||
expect(launch).toMatchObject({
|
||||
providerSessionId: 'provider-current',
|
||||
resumeLeafUuid: 'leaf-current',
|
||||
resumed: true
|
||||
})
|
||||
expect(launch.options.resume).toBe('provider-current')
|
||||
expect(launch.options.resumeSessionAt).toBe('leaf-current')
|
||||
expect(launch.options.sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a durable journal leaf that diverged before resume resolution', async () => {
|
||||
const resolve = resolverFor(
|
||||
record({
|
||||
providerHandleChain: [
|
||||
{
|
||||
handle: {
|
||||
provider: 'claude',
|
||||
sessionId: 'provider-current',
|
||||
leafUuid: 'leaf-current'
|
||||
}
|
||||
}
|
||||
] as AgentSessionRecord['providerHandleChain']
|
||||
})
|
||||
)
|
||||
|
||||
await expect(resolve({ identity: identityAt('leaf-stale') })).rejects.toThrow(
|
||||
'durable resume identity changed before spawn'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps session-only resume when the durable handle has no leaf', async () => {
|
||||
const launch = await resolverFor(
|
||||
record({
|
||||
providerHandleChain: [
|
||||
{
|
||||
handle: {
|
||||
provider: 'claude',
|
||||
sessionId: 'provider-current',
|
||||
leafUuid: null
|
||||
}
|
||||
}
|
||||
] as AgentSessionRecord['providerHandleChain']
|
||||
})
|
||||
)({ identity: identityAt(null) })
|
||||
|
||||
expect(launch.options.resume).toBe('provider-current')
|
||||
expect(launch.options.resumeSessionAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves durable Claude launch arguments as typed options and extraArgs', async () => {
|
||||
const launch = await resolverFor(
|
||||
record({
|
||||
launchArgs: [
|
||||
'--model',
|
||||
'claude-sonnet-4-5',
|
||||
'--effort',
|
||||
'high',
|
||||
'--dangerously-skip-permissions'
|
||||
]
|
||||
})
|
||||
)({ identity: IDENTITY })
|
||||
|
||||
expect(launch.options.model).toBe('claude-sonnet-4-5')
|
||||
expect(launch.options.effort).toBe('high')
|
||||
expect(launch.options.extraArgs).toEqual({
|
||||
'dangerously-skip-permissions': null,
|
||||
'replay-user-messages': null
|
||||
})
|
||||
})
|
||||
|
||||
it('routes durable launch arguments to a typed option first and refuses what neither can carry', () => {
|
||||
// The catalog's own output: each flag lands in exactly one place, so the SDK
|
||||
// cannot emit it twice with two different values.
|
||||
expect(claudeSdkOptionsForLaunchArgs(['--model', 'opus', '--effort', 'xhigh'])).toEqual({
|
||||
model: 'opus',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
// An effort the SDK's union does not name still reaches the CLI, unchanged.
|
||||
expect(claudeSdkOptionsForLaunchArgs(['--effort', 'ultra'])).toEqual({
|
||||
extraArgs: { effort: 'ultra' }
|
||||
})
|
||||
expect(claudeSdkOptionsForLaunchArgs(['--settings=/tmp/s.json'])).toEqual({
|
||||
extraArgs: { settings: '/tmp/s.json' }
|
||||
})
|
||||
expect(() => claudeSdkOptionsForLaunchArgs(['-m', 'opus'])).toThrow(/no SDK option/)
|
||||
})
|
||||
|
||||
it('keeps the session launch environment pinned after account settings change', async () => {
|
||||
const resolver = resolverFor(record(), () => ({
|
||||
ANTHROPIC_AUTH_TOKEN: 'rotated-token',
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
|
||||
}))
|
||||
|
||||
expect((await resolver({ identity: IDENTITY })).env).toMatchObject({
|
||||
ANTHROPIC_AUTH_TOKEN: 'rotated-token',
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
|
||||
})
|
||||
expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token')
|
||||
})
|
||||
|
||||
// Stripping is the managed-account rule the terminal preflight computes at
|
||||
// runtime-auth-preparation.ts:72; claude-structured-auth-parity.test.ts covers
|
||||
// the system-auth half, where the user's own key has to survive.
|
||||
it('strips ambient Anthropic auth under a managed account but keeps the rest of the env', async () => {
|
||||
const restore = {
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
ORCA_LAUNCH_RESOLUTION_MARKER: process.env.ORCA_LAUNCH_RESOLUTION_MARKER
|
||||
}
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'tok-SHELL-LEAK'
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-SHELL-LEAK'
|
||||
process.env.ORCA_LAUNCH_RESOLUTION_MARKER = 'inherited'
|
||||
try {
|
||||
const launch = await resolverFor(record(), undefined, true)({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(launch.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
expect(launch.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
|
||||
// The inherited env is still the base — only auth is removed from it.
|
||||
expect(launch.env?.ORCA_LAUNCH_RESOLUTION_MARKER).toBe('inherited')
|
||||
expect(launch.env?.PATH ?? launch.env?.Path).toBeTruthy()
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(restore)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('lets an explicit Claude env overlay override ambient auth under system auth', async () => {
|
||||
const restore = process.env.ANTHROPIC_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK'
|
||||
try {
|
||||
const launch = await resolverFor(record(), () => ({
|
||||
ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED'
|
||||
}))({ identity: IDENTITY })
|
||||
|
||||
expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED')
|
||||
} finally {
|
||||
if (restore === undefined) {
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY = restore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('pairs a resolved Claude CLI with its sibling Node runtime', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-claude-launch-'))
|
||||
const binDir = join(root, 'bin')
|
||||
const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude')
|
||||
const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')
|
||||
makeExecutable(claudeCommand)
|
||||
makeExecutable(nodeCommand)
|
||||
|
||||
const launch = await createClaudeStructuredLaunchResolver({
|
||||
store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async (id) => `/repos/${id}`,
|
||||
resolveCommand: () => claudeCommand,
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: false }),
|
||||
resolveEnv: () => ({
|
||||
PATH: '/usr/bin',
|
||||
CLAUDE_CONFIG_DIR: '/accounts/selected/home'
|
||||
})
|
||||
})({ identity: IDENTITY })
|
||||
|
||||
expect((launch.env?.PATH ?? launch.env?.Path)?.split(delimiter)[0]).toBe(binDir)
|
||||
})
|
||||
|
||||
it('refuses other hosts, WSL, providers, and account-home variables', async () => {
|
||||
await expect(
|
||||
resolverFor(record({ location: { ...record().location, executionHostId: 'ssh:build' } }))({
|
||||
identity: IDENTITY
|
||||
})
|
||||
).rejects.toThrow(/local host/)
|
||||
await expect(
|
||||
resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({
|
||||
identity: IDENTITY
|
||||
})
|
||||
).rejects.toThrow(/local host/)
|
||||
await expect(
|
||||
resolverFor(record({ provider: 'codex' } as Partial<AgentSessionRecord>))({
|
||||
identity: IDENTITY
|
||||
})
|
||||
).rejects.toThrow(/codex session/)
|
||||
await expect(
|
||||
resolverFor(record({ accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' } }))({
|
||||
identity: IDENTITY
|
||||
})
|
||||
).rejects.toThrow(/CLAUDE_CONFIG_DIR/)
|
||||
})
|
||||
|
||||
/** The account state can change while a session lives, and a reacquire after an unexpected child
|
||||
* exit re-resolves the launch. Without the gate here, that reacquire spawns under whatever the
|
||||
* account state has become. */
|
||||
describe('managed-account gate on every acquisition', () => {
|
||||
function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) {
|
||||
return createClaudeStructuredLaunchResolver({
|
||||
store: { getRecord: () => RESUMABLE } as unknown as AgentSessionRecordStore,
|
||||
resolveWorkspacePath: async (id) => `/repos/${id}`,
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
// Derived, not a literal: the gate and the policy must read the SAME account state, so a
|
||||
// hardcoded value could assert a pairing production cannot produce.
|
||||
resolveAuthPolicy: () => {
|
||||
const settings = read()
|
||||
if (!settings) {
|
||||
throw new Error('the gate refuses before the auth policy is computed')
|
||||
}
|
||||
return claudeStructuredAuthPolicyForSettings(settings)
|
||||
},
|
||||
readManagedAccountGate: read
|
||||
})
|
||||
}
|
||||
|
||||
it('refuses a reacquire once the account state becomes the refused shape', async () => {
|
||||
let gate: ClaudeManagedAccountGateSettings | null = HOST_SELECTED
|
||||
const resolve = resolverWithGate(() => gate)
|
||||
|
||||
// Created while supported: the launch resolves and would spawn.
|
||||
await expect(resolve({ identity: identityAt('leaf-current') })).resolves.toMatchObject({
|
||||
providerSessionId: 'provider-current'
|
||||
})
|
||||
|
||||
gate = WSL_ONLY_NORMALIZED
|
||||
|
||||
// Reacquire after the account state changed: refused before anything spawns.
|
||||
await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toBeInstanceOf(
|
||||
AgentSessionPreSpawnError
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when the account state cannot be read', async () => {
|
||||
await expect(
|
||||
resolverWithGate(() => null)({ identity: identityAt('leaf-current') })
|
||||
).rejects.toBeInstanceOf(AgentSessionPreSpawnError)
|
||||
})
|
||||
|
||||
it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => {
|
||||
await expect(
|
||||
resolverFor(RESUMABLE)({ identity: identityAt('leaf-current') })
|
||||
).resolves.toMatchObject({ providerSessionId: 'provider-current' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { EffortLevel, Options as ClaudeAgentSdkOptions } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
|
||||
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE,
|
||||
applyClaudeEnvPatch,
|
||||
hasClaudeAuthEnvConflict
|
||||
} from '../claude-accounts/environment'
|
||||
import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy'
|
||||
import {
|
||||
CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS,
|
||||
whenClaudeAuthSwitchSettles
|
||||
} from '../claude-accounts/live-pty-gate'
|
||||
import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import {
|
||||
structuredClaudeMatchesActiveManagedAccount,
|
||||
type ClaudeManagedAccountGateSettings
|
||||
} from '../native-chat/claude-structured-managed-account-support'
|
||||
import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
||||
|
||||
export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const
|
||||
|
||||
export type ClaudeStructuredSdkOptions = Pick<
|
||||
ClaudeAgentSdkOptions,
|
||||
| 'includePartialMessages'
|
||||
| 'systemPrompt'
|
||||
| 'settingSources'
|
||||
| 'supportedDialogKinds'
|
||||
| 'extraArgs'
|
||||
| 'model'
|
||||
| 'effort'
|
||||
| 'sessionId'
|
||||
| 'resume'
|
||||
| 'resumeSessionAt'
|
||||
>
|
||||
|
||||
/**
|
||||
* The options translation of the flags this transport used to build by hand.
|
||||
*
|
||||
* `-p`, `--input-format`, `--output-format` and `--verbose` are implied by
|
||||
* `query()`; `--permission-prompt-tool stdio` is emitted because a `canUseTool`
|
||||
* callback is supplied. `--replay-user-messages` has no option — the SDK never
|
||||
* emits it — and Orca's send acknowledgement depends on the replay.
|
||||
*/
|
||||
export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = {
|
||||
includePartialMessages: true,
|
||||
// Keep the SDK on Claude Code's own system-prompt contract.
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
settingSources: [...CLAUDE_DEFAULT_SETTING_SOURCES],
|
||||
supportedDialogKinds: [],
|
||||
extraArgs: { 'replay-user-messages': null }
|
||||
}
|
||||
|
||||
const EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
|
||||
function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value !== undefined) {
|
||||
next[key] = value
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the record's durable launch arguments into SDK options.
|
||||
*
|
||||
* Typed option first so a flag is never emitted twice; `extraArgs` carries
|
||||
* anything without one. A token expressible neither way is refused rather than
|
||||
* dropped — a silent drop is how this lane loses launch flags.
|
||||
*/
|
||||
export function claudeSdkOptionsForLaunchArgs(
|
||||
args: readonly string[]
|
||||
): Pick<ClaudeStructuredSdkOptions, 'model' | 'effort' | 'extraArgs'> {
|
||||
let model: string | undefined
|
||||
let effort: EffortLevel | undefined
|
||||
const extraArgs: Record<string, string | null> = {}
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const token = args[index] ?? ''
|
||||
if (!token.startsWith('--') || token.length <= 2) {
|
||||
throw new Error(
|
||||
`claude launch argument ${token} has no SDK option; refusing rather than dropping it`
|
||||
)
|
||||
}
|
||||
const equals = token.indexOf('=')
|
||||
const flag = equals === -1 ? token : token.slice(0, equals)
|
||||
let value = equals === -1 ? null : token.slice(equals + 1)
|
||||
if (value === null) {
|
||||
const next = args[index + 1]
|
||||
if (next !== undefined && !next.startsWith('-')) {
|
||||
value = next
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
if (flag === '--model' && value !== null) {
|
||||
model = value
|
||||
} else if (flag === '--effort' && value !== null && EFFORT_LEVELS.includes(value)) {
|
||||
effort = value as EffortLevel
|
||||
} else {
|
||||
extraArgs[flag.slice(2)] = value
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(model === undefined ? {} : { model }),
|
||||
...(effort === undefined ? {} : { effort }),
|
||||
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudeStructuredLaunch = {
|
||||
/** Always Orca's resolved user CLI: the SDK's bundled binaries are excluded from the install. */
|
||||
pathToClaudeCodeExecutable: string
|
||||
options: ClaudeStructuredSdkOptions
|
||||
cwd: string
|
||||
env?: Record<string, string>
|
||||
claudeConfigDir: string
|
||||
providerSessionId: string
|
||||
resumeLeafUuid: string | null
|
||||
resumed: boolean
|
||||
}
|
||||
|
||||
export type ClaudeStructuredLaunchResolverDeps = {
|
||||
store: AgentSessionRecordStore
|
||||
resolveWorkspacePath: (workspaceId: string) => Promise<string>
|
||||
resolveCommand?: () => string
|
||||
resolveEnv?: () =>
|
||||
| Promise<Record<string, string> | undefined>
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
/**
|
||||
* Required, and deliberately not defaulted. `stripAuthEnv` used to be a literal
|
||||
* `true` here, so a missing dependency could not under-strip. Now it can, and the
|
||||
* failure is silent — so every caller states the account's policy rather than
|
||||
* inherit a guess. Build it with claudeStructuredAuthPolicyForSettings.
|
||||
*/
|
||||
resolveAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
|
||||
/** How long an in-flight account switch may hold a launch before it is refused. */
|
||||
authSwitchSettleTimeoutMs?: number
|
||||
/** Account state for the managed-account gate; null when it cannot be read, which refuses. */
|
||||
readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait a running account switch out, and refuse only if it never settles.
|
||||
*
|
||||
* Launch resolution is reached from `acquireClaudeSession` *after* the old child has
|
||||
* been closed and proved, so a plain refusal here would leave the user with a dead
|
||||
* chat and no replacement — the very harm the acquire-entry guard exists to prevent.
|
||||
* The entry guard still refuses outright, because nothing has been torn down yet.
|
||||
*/
|
||||
export async function assertClaudeAuthSwitchSettled(
|
||||
timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS
|
||||
): Promise<void> {
|
||||
if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) {
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
export function claudeSessionIdForOrcaSession(sessionId: string): string {
|
||||
const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16)
|
||||
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40
|
||||
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80
|
||||
const hex = bytes.toString('hex')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
export function createClaudeStructuredLaunchResolver(
|
||||
deps: ClaudeStructuredLaunchResolverDeps
|
||||
): (input: { identity: AgentSessionJournalIdentity }) => Promise<ClaudeStructuredLaunch> {
|
||||
return async ({ identity }) => {
|
||||
await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs)
|
||||
const record = deps.store.getRecord(identity.sessionId)
|
||||
if (!record) {
|
||||
throw new Error(`no durable agent-session record for ${identity.sessionId}`)
|
||||
}
|
||||
if (record.provider !== 'claude') {
|
||||
throw new Error(`session ${identity.sessionId} is a ${record.provider} session`)
|
||||
}
|
||||
if (
|
||||
record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
|
||||
record.location.wslDistro !== null
|
||||
) {
|
||||
throw new Error(
|
||||
`claude structured sessions run on the local host, not ${record.location.executionHostId}`
|
||||
)
|
||||
}
|
||||
if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') {
|
||||
throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`)
|
||||
}
|
||||
// Every acquisition, not just the first: the account state can change under a live session, and
|
||||
// a reacquire after an unexpected exit would otherwise spawn under whatever it has become.
|
||||
// Codex has no gate here — it resolves its account on a different path.
|
||||
if (
|
||||
deps.readManagedAccountGate &&
|
||||
!structuredClaudeMatchesActiveManagedAccount(deps.readManagedAccountGate())
|
||||
) {
|
||||
throw new AgentSessionPreSpawnError(
|
||||
'structured Claude is not offered under the active managed Claude account'
|
||||
)
|
||||
}
|
||||
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
|
||||
if (
|
||||
head?.handle.provider === 'claude' &&
|
||||
(identity.providerHandle.kind !== 'claude' ||
|
||||
identity.providerHandle.sessionId !== head.handle.sessionId ||
|
||||
identity.providerHandle.leafUuid !== head.handle.leafUuid)
|
||||
) {
|
||||
throw new Error('claude durable resume identity changed before spawn')
|
||||
}
|
||||
const providerSessionId =
|
||||
head?.handle.provider === 'claude'
|
||||
? head.handle.sessionId
|
||||
: claudeSessionIdForOrcaSession(identity.sessionId)
|
||||
const durable = claudeSdkOptionsForLaunchArgs(record.launchArgs ?? [])
|
||||
const command = (deps.resolveCommand ?? resolveClaudeCommand)()
|
||||
const auth = await deps.resolveAuthPolicy()
|
||||
const overlay = await deps.resolveEnv?.()
|
||||
// A switch can begin while the policy and overlay resolve, exactly as it can
|
||||
// during the terminal preflight's prepareClaudeAuth — recheck after the awaits.
|
||||
await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs)
|
||||
// Under a managed account the pinned credential is the only auth this launch may
|
||||
// use, so an explicit override is refused rather than silently beating the pin.
|
||||
if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) {
|
||||
throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
}
|
||||
// Why the overlay merges onto the inherited env rather than replacing it: the child
|
||||
// still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath
|
||||
// derives PATH from what it is handed. Ambient Anthropic auth is stripped from the
|
||||
// inherited half only when a managed account owns the credential; a system-auth
|
||||
// user's own key is their sign-in and must reach the child.
|
||||
const env = withCliRuntimeOnPath(
|
||||
command,
|
||||
{
|
||||
...applyClaudeEnvPatch(
|
||||
cloneDefinedEnv(process.env),
|
||||
{},
|
||||
{
|
||||
stripAuthEnv: auth.stripAuthEnv,
|
||||
platform: process.platform
|
||||
}
|
||||
),
|
||||
...(overlay ? cloneDefinedEnv(overlay) : {})
|
||||
},
|
||||
{ platform: process.platform }
|
||||
)
|
||||
return {
|
||||
pathToClaudeCodeExecutable: command,
|
||||
options: {
|
||||
...durable,
|
||||
...CLAUDE_STRUCTURED_BASE_OPTIONS,
|
||||
extraArgs: { ...durable.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs },
|
||||
...(head?.handle.provider === 'claude'
|
||||
? {
|
||||
resume: providerSessionId,
|
||||
...(head.handle.leafUuid === null ? {} : { resumeSessionAt: head.handle.leafUuid })
|
||||
}
|
||||
: { sessionId: providerSessionId })
|
||||
},
|
||||
cwd: await deps.resolveWorkspacePath(record.location.workspaceId),
|
||||
env,
|
||||
claudeConfigDir: record.accountHome.path,
|
||||
providerSessionId,
|
||||
resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null,
|
||||
resumed: head?.handle.provider === 'claude'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
__setWindowsProcessTreeLoaderForTests,
|
||||
resetWindowsProcessTableForTests
|
||||
} from '../windows/windows-process-table'
|
||||
import { supportsClaudeStructuredLocation } from './claude-structured-location-support'
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): PropertyDescriptor | undefined {
|
||||
const previous = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
return previous
|
||||
}
|
||||
|
||||
describe('supportsClaudeStructuredLocation', () => {
|
||||
let previousPlatform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
previousPlatform = setPlatform('darwin')
|
||||
__setWindowsProcessTreeLoaderForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
__setWindowsProcessTreeLoaderForTests()
|
||||
resetWindowsProcessTableForTests()
|
||||
if (previousPlatform) {
|
||||
Object.defineProperty(process, 'platform', previousPlatform)
|
||||
}
|
||||
})
|
||||
|
||||
it('allows local non-WSL locations on macOS and Linux', () => {
|
||||
expect(
|
||||
supportsClaudeStructuredLocation({
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects Windows local locations until creation-time proof is available', () => {
|
||||
previousPlatform = setPlatform('win32')
|
||||
__setWindowsProcessTreeLoaderForTests(() => ({
|
||||
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
|
||||
getAllProcesses: () => undefined
|
||||
}))
|
||||
expect(
|
||||
supportsClaudeStructuredLocation({
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts Windows local locations once creation-time proof is available', () => {
|
||||
previousPlatform = setPlatform('win32')
|
||||
__setWindowsProcessTreeLoaderForTests(() => ({
|
||||
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 },
|
||||
getAllProcesses: () => undefined
|
||||
}))
|
||||
expect(
|
||||
supportsClaudeStructuredLocation({
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects WSL and remote locations', () => {
|
||||
expect(
|
||||
supportsClaudeStructuredLocation({
|
||||
executionHostId: 'local',
|
||||
wslDistro: 'Ubuntu',
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
supportsClaudeStructuredLocation({
|
||||
executionHostId: 'runtime:env-1',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
|
||||
|
||||
export function supportsClaudeStructuredLocation(location: AgentSessionExecutionLocation): boolean {
|
||||
return (
|
||||
location.executionHostId === LOCAL_EXECUTION_HOST_ID &&
|
||||
location.wslDistro === null &&
|
||||
(process.platform !== 'win32' || isWindowsProcessStartTimeAvailable())
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { setClaudeStructuredOption } from './claude-structured-options'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support'
|
||||
|
||||
/** Verbatim rows from Claude Code 2.1.258's list_models response. */
|
||||
const CATALOG = [
|
||||
{
|
||||
value: 'default',
|
||||
resolvedModel: 'claude-opus-5[1m]',
|
||||
displayName: 'Default (recommended)',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
},
|
||||
{
|
||||
value: 'sonnet',
|
||||
resolvedModel: 'claude-sonnet-5',
|
||||
displayName: 'Sonnet',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
},
|
||||
{ value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' }
|
||||
]
|
||||
|
||||
function initFrame(model: string): Record<string, unknown> {
|
||||
// Keys mirror the real per-turn system/init frame: it carries `model` as the
|
||||
// resolved id, and no effort of any kind.
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'turn-init-uuid',
|
||||
model,
|
||||
apiKeySource: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude model confirmation', () => {
|
||||
it('adopts the model a later turn reports when nothing was set since', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
|
||||
current: { model: 'sonnet' }
|
||||
})
|
||||
|
||||
// The CLI's own report of what it is running — the only channel that carries
|
||||
// it, since set_model answers success for a model it never resolves.
|
||||
claude.connections[0]!.handlers.onMessage?.(initFrame('claude-haiku-4-5-20251001'))
|
||||
|
||||
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
|
||||
current: { model: 'haiku' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a just-set model until the next turn reports one', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
|
||||
// No turn has run, so the acquisition-time report is older than the write and
|
||||
// must not flip the pill back to the model the session started on.
|
||||
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
|
||||
current: { model: 'haiku' }
|
||||
})
|
||||
})
|
||||
|
||||
it('corrects the record when the turn runs a different model than was set', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
|
||||
|
||||
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
|
||||
current: { model: 'sonnet' }
|
||||
})
|
||||
})
|
||||
|
||||
it('guards an effort against the model the turn reported, not the one that was set', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
// set_model answered success for a model it never resolved; the turn runs sonnet.
|
||||
claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
|
||||
|
||||
// The picker offers sonnet's levels, so refusing one under haiku — a model the
|
||||
// pill does not show and the child is not running — is the false positive.
|
||||
await expect(
|
||||
adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
|
||||
).resolves.toMatchObject({ effort: 'high' })
|
||||
})
|
||||
|
||||
it('keeps guarding against the reported model across a second effort write', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
|
||||
|
||||
// The effort write bumps the option fence but does not change what the child
|
||||
// runs, so the sonnet report is still current and still governs the guard.
|
||||
// `max` skips the settings readback by contract, so only the catalog gates it:
|
||||
// sonnet advertises it, haiku advertises no effort control at all.
|
||||
await expect(
|
||||
adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'max', fence: 7 })
|
||||
).resolves.toMatchObject({ effort: 'max' })
|
||||
})
|
||||
|
||||
it('guards an effort against a just-set model no turn has reported yet', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
|
||||
// The acquisition-time report predates the write, so haiku — which advertises
|
||||
// no effort control — is still the model the guard must answer for.
|
||||
await expect(
|
||||
adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
|
||||
).rejects.toThrow('claude model haiku does not accept effort high')
|
||||
})
|
||||
|
||||
it('stops vouching for a confirmed effort once the model changes', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
|
||||
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
|
||||
current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] }
|
||||
})
|
||||
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
|
||||
// The readback was taken under sonnet; nothing has reported haiku holding it.
|
||||
const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(options.current.effort).toBe('high')
|
||||
expect(options.current.confirmed).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Claude effort the settings readback cannot report', () => {
|
||||
function sessionWith(
|
||||
reported: string,
|
||||
calls: string[] = []
|
||||
): { session: ClaudeSession; calls: string[] } {
|
||||
return {
|
||||
session: {
|
||||
options: new Map<string, string>([['model', 'sonnet']]),
|
||||
reportedOptions: {},
|
||||
optionMutationSequence: 0,
|
||||
confirmedOptions: new Set<string>(),
|
||||
connection: {
|
||||
supportedModels: async () => {
|
||||
calls.push('list_models')
|
||||
return CATALOG
|
||||
},
|
||||
applyFlagSettings: async (settings: { effortLevel?: string }) => {
|
||||
calls.push(`apply:${settings.effortLevel}`)
|
||||
},
|
||||
getSettings: async () => {
|
||||
calls.push('get_settings')
|
||||
return {
|
||||
applied: { effort: reported },
|
||||
effective: { effortLevel: reported },
|
||||
sources: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as unknown as ClaudeSession,
|
||||
calls
|
||||
}
|
||||
}
|
||||
|
||||
it('records a session-scoped effort the persisted settings never carry', async () => {
|
||||
// `max` applies for the session and is deliberately excluded from the
|
||||
// persisted effortLevel, so the readback reporting `high` is an absence of
|
||||
// evidence, not a refusal — and the CLI offers `max` in its own catalog.
|
||||
const { session, calls } = sessionWith('high')
|
||||
|
||||
await expect(
|
||||
setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined)
|
||||
).resolves.toEqual({ model: 'sonnet', effort: 'max' })
|
||||
expect(calls).toEqual(['list_models', 'apply:max'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyStructuredAgentSessionOptions,
|
||||
createStructuredAgentSessionOptionState,
|
||||
structuredAgentSessionOptionSnapshot
|
||||
} from '../../shared/structured-agent-session-options'
|
||||
import { CLAUDE_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex'
|
||||
import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire'
|
||||
import type { SessionOptionDescriptor } from '../../shared/native-chat-session-options'
|
||||
import { setClaudeStructuredOption } from './claude-structured-options'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support'
|
||||
|
||||
/** Verbatim rows from Claude Code 2.1.260's list_models response: `haiku` really
|
||||
* does omit both effort keys, which is what makes an effort under it refusable. */
|
||||
const CATALOG = [
|
||||
{
|
||||
value: 'sonnet',
|
||||
resolvedModel: 'claude-sonnet-5',
|
||||
displayName: 'Sonnet',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
},
|
||||
{ value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' }
|
||||
]
|
||||
|
||||
function initFrame(model: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'turn-init-uuid',
|
||||
model,
|
||||
apiKeySource: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
function modelPill(result: AgentSessionOptionsResult): SessionOptionDescriptor | undefined {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('claude'),
|
||||
CLAUDE_SESSION_OPTION_CATALOG,
|
||||
result
|
||||
)
|
||||
return structuredAgentSessionOptionSnapshot(state).find((d) => d.category === 'model')
|
||||
}
|
||||
|
||||
/** Provenance the record keeps. Nothing renders it — the pill shows the value
|
||||
* either way, and a report that disagrees is what corrects it. */
|
||||
function modelSource(result: AgentSessionOptionsResult): string | undefined {
|
||||
return modelPill(result)?.valueSource
|
||||
}
|
||||
|
||||
function modelValue(result: AgentSessionOptionsResult): string | undefined {
|
||||
const kind = modelPill(result)?.kind
|
||||
return kind?.type === 'select' ? kind.currentValue : undefined
|
||||
}
|
||||
|
||||
describe('structured option confirmation reaches the pill', () => {
|
||||
it('shows a just-set model before any turn reports it', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
|
||||
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(result.current.confirmed ?? []).not.toContain('model')
|
||||
expect(modelSource(result)).toBe('dispatched')
|
||||
})
|
||||
|
||||
it('marks the model reported once the provider names it back', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
claude.connections[0]!.handlers.onMessage?.(initFrame('claude-haiku-4-5-20251001'))
|
||||
|
||||
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(result.current.confirmed).toContain('model')
|
||||
expect(modelSource(result)).toBe('reported')
|
||||
})
|
||||
|
||||
it('records an effort the readback could not take without confirming it', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
settings: { applied: {}, effective: {}, sources: {} },
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
// `max` is session-scoped and absent from the persisted settings, so it records
|
||||
// without a readback — recorded, never vouched for.
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'max', fence: 7 })
|
||||
|
||||
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(result.current.effort).toBe('max')
|
||||
expect(result.current.confirmed ?? []).not.toContain('effort')
|
||||
})
|
||||
|
||||
it('confirms an effort the readback agreed with', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
settings: { applied: { effort: 'low' }, effective: { effortLevel: 'low' }, sources: {} },
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'low', fence: 7 })
|
||||
|
||||
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(result.current.confirmed).toContain('effort')
|
||||
})
|
||||
|
||||
it('treats a host that reports no confirmation as unconfirmed', () => {
|
||||
// Wire compatibility: an older host omits `confirmed` entirely. Absence must
|
||||
// read as unconfirmed provenance, and the pill still shows the host's value.
|
||||
const result = {
|
||||
models: [{ id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] }],
|
||||
current: { model: 'haiku' }
|
||||
}
|
||||
expect(modelSource(result)).toBe('dispatched')
|
||||
expect(modelValue(result)).toBe('haiku')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the provider report corrects the pill', () => {
|
||||
it('moves the pill to the model the turn actually ran', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
expect(modelValue(await adapter.readOptions({ sessionId: 'session-1', fence: 7 }))).toBe(
|
||||
'haiku'
|
||||
)
|
||||
|
||||
claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
|
||||
|
||||
const corrected = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(modelValue(corrected)).toBe('sonnet')
|
||||
expect(corrected.current.confirmed).toContain('model')
|
||||
})
|
||||
|
||||
it('lets a newer write outrank the report it precedes', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5'))
|
||||
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 })
|
||||
|
||||
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(modelValue(result)).toBe('haiku')
|
||||
expect(result.current.confirmed ?? []).not.toContain('model')
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmation never outlives the write it belongs to', () => {
|
||||
it('drops an earlier effort confirmation when the value changes', async () => {
|
||||
const calls: string[] = []
|
||||
let reported = 'low'
|
||||
const session = {
|
||||
options: new Map<string, string>([['model', 'sonnet']]),
|
||||
reportedOptions: {},
|
||||
optionMutationSequence: 0,
|
||||
confirmedOptions: new Set<string>(),
|
||||
connection: {
|
||||
supportedModels: async () => CATALOG,
|
||||
applyFlagSettings: async (s: { effortLevel?: string }) => {
|
||||
calls.push(`apply:${s.effortLevel}`)
|
||||
},
|
||||
getSettings: async () => ({
|
||||
applied: { effort: reported },
|
||||
effective: { effortLevel: reported },
|
||||
sources: {}
|
||||
})
|
||||
}
|
||||
} as unknown as ClaudeSession
|
||||
|
||||
await setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
|
||||
expect(session.confirmedOptions.has('effort')).toBe(true)
|
||||
|
||||
// The provider now reports a level it cannot represent; the stale confirmation
|
||||
// must not survive into the new value.
|
||||
await setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined)
|
||||
expect(session.options.get('effort')).toBe('max')
|
||||
expect(session.confirmedOptions.has('effort')).toBe(false)
|
||||
expect(calls).toEqual(['apply:low', 'apply:max'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setClaudeStructuredOption } from './claude-structured-options'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession {
|
||||
return {
|
||||
connection: { setModel } as ClaudeSession['connection'],
|
||||
providerSessionId: 'provider-session',
|
||||
claudeConfigDir: '/accounts/claude',
|
||||
leafUuid: null,
|
||||
fence: 1,
|
||||
acquisitionGeneration: 'generation-1',
|
||||
prompts: {} as ClaudeSession['prompts'],
|
||||
dispatchWaiters: [],
|
||||
retiredDispatchWaiters: [],
|
||||
replayContentFallbackBlocked: false,
|
||||
dispatchSequence: 0,
|
||||
optionMutationSequence: 0,
|
||||
options: new Map(),
|
||||
reportedOptions: {},
|
||||
reportedModelMutation: 0,
|
||||
confirmedOptions: new Set(),
|
||||
restoreSkippedOptions: new Set(),
|
||||
capabilities: [],
|
||||
events: undefined,
|
||||
translator: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude structured option mutation fencing', () => {
|
||||
it('does not let a delayed earlier apply overwrite a later option', async () => {
|
||||
let releaseFirst!: () => void
|
||||
const firstApply = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
const setModel = vi
|
||||
.fn<ClaudeSession['connection']['setModel']>()
|
||||
.mockReturnValueOnce(firstApply)
|
||||
.mockResolvedValue(undefined)
|
||||
const session = sessionFor(setModel)
|
||||
|
||||
const first = setClaudeStructuredOption(session, { key: 'model', value: 'old' }, undefined)
|
||||
await vi.waitFor(() => expect(setModel).toHaveBeenCalledTimes(1))
|
||||
const second = setClaudeStructuredOption(session, { key: 'model', value: 'new' }, undefined)
|
||||
await expect(second).resolves.toEqual({ model: 'new' })
|
||||
|
||||
releaseFirst()
|
||||
await expect(first).resolves.toEqual({ model: 'new' })
|
||||
expect(session.options).toEqual(new Map([['model', 'new']]))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { ClaudeControlRequestError } from './claude-stream-json-connection'
|
||||
import {
|
||||
AgentSessionOptionRejectedError,
|
||||
isAgentSessionOptionRejectedError
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-option-error'
|
||||
import {
|
||||
readClaudeCurrentModel,
|
||||
readClaudeModelEffortLevels,
|
||||
readClaudeSettingsEffort
|
||||
} from './claude-structured-session-options'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
const OPTION_ORDER = ['model', 'effort', 'permissionMode'] as const
|
||||
|
||||
/**
|
||||
* Efforts the settings readback cannot report. `max` applies for the rest of the
|
||||
* session and is excluded from the persisted `effortLevel` by contract, so
|
||||
* `get_settings` answers with the level underneath it — an absence of evidence
|
||||
* that must not be read as the child refusing a level its own catalog offers.
|
||||
*/
|
||||
const UNREPORTED_EFFORTS: ReadonlySet<string> = new Set(['max'])
|
||||
|
||||
export function restoredClaudeStructuredSessionOptions(
|
||||
options: Readonly<Record<string, string>> | undefined
|
||||
): Map<string, string> {
|
||||
return new Map(
|
||||
OPTION_ORDER.flatMap((key) => {
|
||||
const value = options?.[key]
|
||||
return value ? [[key, value] as const] : []
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function setClaudeStructuredOption(
|
||||
session: ClaudeSession,
|
||||
input: { key: string; value: string },
|
||||
timeoutMs: number | undefined
|
||||
): Promise<Readonly<Record<string, string>>> {
|
||||
const apply =
|
||||
input.key === 'model'
|
||||
? () => session.connection.setModel(input.value, { timeoutMs })
|
||||
: input.key === 'permissionMode'
|
||||
? () => session.connection.setPermissionMode(input.value as PermissionMode, { timeoutMs })
|
||||
: input.key === 'effort'
|
||||
? () =>
|
||||
session.connection.applyFlagSettings(
|
||||
{ effortLevel: input.value as EffortLevel },
|
||||
{ timeoutMs }
|
||||
)
|
||||
: null
|
||||
if (!apply) {
|
||||
throw new AgentSessionOptionRejectedError(
|
||||
`claude stream-json has no session option named ${input.key}`
|
||||
)
|
||||
}
|
||||
// The child stores an effort its model has no control for and keeps it across
|
||||
// every later model switch and restore, so refuse before the write rather than
|
||||
// read the acceptance back as adoption. Refused here, restore drops the stale
|
||||
// value instead of replaying it onto a model that cannot use it.
|
||||
if (input.key === 'effort') {
|
||||
const { modelId, levels } = await readClaudeModelEffortLevels(session, timeoutMs)
|
||||
if (levels && !levels.has(input.value)) {
|
||||
throw new AgentSessionOptionRejectedError(
|
||||
`claude model ${modelId} does not accept effort ${input.value}`
|
||||
)
|
||||
}
|
||||
}
|
||||
const modelWasConfirmed = readClaudeCurrentModel(session).confirmed
|
||||
const mutationSequence = ++session.optionMutationSequence
|
||||
// Only a model write can stale the model report — an effort or permission-mode
|
||||
// write does not change what the child is running. Leaving the stamp behind
|
||||
// would drop the session back to the written model and refuse, on the next
|
||||
// effort write, a level the model actually running advertises.
|
||||
if (modelWasConfirmed && input.key !== 'model') {
|
||||
session.reportedModelMutation = mutationSequence
|
||||
}
|
||||
try {
|
||||
await apply()
|
||||
} catch (error) {
|
||||
if (error instanceof ClaudeControlRequestError) {
|
||||
throw new AgentSessionOptionRejectedError(error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// apply_flag_settings answers `success` for an effort it then ignores, so the
|
||||
// absence of a throw proves nothing. Ask what the child actually holds.
|
||||
const adopted =
|
||||
input.key === 'effort' && !UNREPORTED_EFFORTS.has(input.value)
|
||||
? await session.connection
|
||||
.getSettings({ timeoutMs })
|
||||
.then(readClaudeSettingsEffort)
|
||||
.catch(() => null)
|
||||
: null
|
||||
if (mutationSequence !== session.optionMutationSequence) {
|
||||
return Object.fromEntries(session.options)
|
||||
}
|
||||
// A disagreement stops main vouching for the value, it does not veto the write:
|
||||
// the pre-flight guard already refuses levels the model advertises no control
|
||||
// for, and no other client refuses on a readback. Keep the child's own answer so
|
||||
// the disagreement survives as the level a later read falls back to.
|
||||
if (adopted !== null && adopted !== input.value) {
|
||||
session.reportedOptions.effort = adopted
|
||||
}
|
||||
session.options.set(input.key, input.value)
|
||||
// Only a readback that agreed is adoption evidence; one that disagreed or could
|
||||
// not be taken records the value but must not also claim the provider vouched for it.
|
||||
if (adopted !== null && adopted === input.value) {
|
||||
session.confirmedOptions.add(input.key)
|
||||
} else {
|
||||
session.confirmedOptions.delete(input.key)
|
||||
}
|
||||
// The effort readback was taken under the old model, so a model switch retires
|
||||
// it: the child keeps the value but nothing has reported the new model holding
|
||||
// it, and vouching for it would show a confirmed effort no readback covers.
|
||||
if (input.key === 'model') {
|
||||
session.confirmedOptions.delete('effort')
|
||||
}
|
||||
return Object.fromEntries(session.options)
|
||||
}
|
||||
|
||||
export async function restoreClaudeStructuredSessionOptions(
|
||||
session: ClaudeSession,
|
||||
timeoutMs: number | undefined
|
||||
): Promise<void> {
|
||||
// Any write that was already in flight belongs to the previous acquisition
|
||||
// state and must not repopulate this map after restore starts.
|
||||
session.optionMutationSequence += 1
|
||||
// The fence bump is not a write, so the report the session already holds is still
|
||||
// current as of this instant; leaving the stamp behind would make every restored
|
||||
// session read as unconfirmed until its next turn.
|
||||
session.reportedModelMutation = session.optionMutationSequence
|
||||
const options = [...session.options.entries()]
|
||||
session.options.clear()
|
||||
for (const [key, value] of options) {
|
||||
try {
|
||||
await setClaudeStructuredOption(session, { key, value }, timeoutMs)
|
||||
} catch (error) {
|
||||
if (!isAgentSessionOptionRejectedError(error)) {
|
||||
throw error
|
||||
}
|
||||
// A stale or unavailable preference must not poison every future acquire;
|
||||
// the provider's current value remains authoritative and is re-persisted.
|
||||
session.restoreSkippedOptions.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity'
|
||||
|
||||
const IDENTITY = {
|
||||
sessionId: 'session-identity',
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'claude' as const,
|
||||
providerHandle: { kind: 'claude' as const, sessionId: 'session-1', leafUuid: 'leaf-1' }
|
||||
}
|
||||
|
||||
describe('claude structured owner identity', () => {
|
||||
it('exports the spawn token env and records the observed process identity', async () => {
|
||||
expect(CLAUDE_SPAWN_TOKEN_ENV).toBe('ORCA_AGENT_SESSION_SPAWN_TOKEN')
|
||||
await expect(
|
||||
claudeProcessIdentity(
|
||||
{ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 },
|
||||
async () => 123
|
||||
)
|
||||
).resolves.toEqual({
|
||||
hostId: 'local',
|
||||
pid: 4242,
|
||||
processStartTimeMs: 123,
|
||||
spawnToken: 'spawn-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a failed start-time read before giving up', async () => {
|
||||
const readStartTime = vi
|
||||
.fn<(pid: number) => Promise<number | null>>()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(456)
|
||||
await expect(
|
||||
claudeProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime)
|
||||
).resolves.toMatchObject({ processStartTimeMs: 456 })
|
||||
expect(readStartTime).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
|
||||
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
|
||||
import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe'
|
||||
|
||||
export function claudeProviderHandleLink(input: {
|
||||
sessionId: string
|
||||
@@ -19,3 +22,41 @@ export function claudeProviderHandleLink(input: {
|
||||
observedAt: input.observedAt
|
||||
}
|
||||
}
|
||||
|
||||
/** The child echoes its spawn token here so the owner probe can tell a live
|
||||
* child of this reservation from a same-pid stranger. */
|
||||
export const CLAUDE_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN'
|
||||
|
||||
const START_TIME_READ_ATTEMPTS = 3
|
||||
|
||||
export async function claudeProcessIdentity(
|
||||
input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
spawnToken: string
|
||||
pid: number | undefined
|
||||
},
|
||||
readStartTime: (pid: number) => Promise<number | null> = readProcessStartTimeMs
|
||||
): Promise<AgentSessionProcessIdentity> {
|
||||
if (input.pid === undefined) {
|
||||
throw new Error('claude app-server started without a pid')
|
||||
}
|
||||
let processStartTimeMs: number | null = null
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null;
|
||||
attempt += 1
|
||||
) {
|
||||
processStartTimeMs = await readStartTime(input.pid)
|
||||
}
|
||||
if (processStartTimeMs === null) {
|
||||
// Why: recording null makes every later owner probe indeterminate — a durable latch.
|
||||
// Failing here reaps the child and leaves a retryable refusal instead.
|
||||
throw new Error(`claude app-server start time for pid ${input.pid} could not be read`)
|
||||
}
|
||||
return {
|
||||
hostId: input.identity.hostId,
|
||||
pid: input.pid,
|
||||
processStartTimeMs,
|
||||
spawnToken: input.spawnToken
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer'
|
||||
import { claudeQuestionItems } from './claude-structured-prompt-items'
|
||||
import {
|
||||
applyClaudePromptAnswer,
|
||||
encodeClaudeQuestionOptionId,
|
||||
type ClaudePendingPrompt
|
||||
} from './claude-structured-prompt-replies'
|
||||
|
||||
describe('Claude structured question addressing', () => {
|
||||
it('keeps wire IDs bounded while returning the original question and choice', () => {
|
||||
const questionId = 'Which option? '.repeat(100)
|
||||
const label = 'A detailed choice '.repeat(100)
|
||||
const prompt: ClaudePendingPrompt = {
|
||||
requestId: 'question-1',
|
||||
promptKey: 'question-1',
|
||||
toolUseId: 'tool-1',
|
||||
toolName: 'AskUserQuestion',
|
||||
kind: 'question',
|
||||
input: { questions: [{ question: questionId, options: [{ label }] }] },
|
||||
suggestions: [],
|
||||
questionIds: [questionId],
|
||||
answers: new Map(),
|
||||
settle: () => {}
|
||||
}
|
||||
|
||||
const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]!
|
||||
expect(agentJournalItemKey(item.identity).length).toBeLessThan(512)
|
||||
expect(item.body.options[0]!.id.length).toBeLessThan(512)
|
||||
expect(item.body.freeTextQuestionId).toBe('q1')
|
||||
expect(applyClaudePromptAnswer({ prompt }, item.body.options[0]!.id)).toMatchObject({
|
||||
updatedInput: { answers: { [questionId]: label } }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves colon-containing free-text answers', () => {
|
||||
const questionId = 'Where should this run?'
|
||||
const prompt: ClaudePendingPrompt = {
|
||||
requestId: 'question-1',
|
||||
promptKey: 'question-1',
|
||||
toolUseId: 'tool-1',
|
||||
toolName: 'AskUserQuestion',
|
||||
kind: 'question',
|
||||
input: { questions: [{ question: questionId }] },
|
||||
suggestions: [],
|
||||
questionIds: [questionId],
|
||||
answers: new Map(),
|
||||
settle: () => {}
|
||||
}
|
||||
const answer = 'https://example.test:8443/path'
|
||||
|
||||
expect(
|
||||
applyClaudePromptAnswer({ prompt }, encodeClaudeQuestionOptionId('q1', answer))
|
||||
).toMatchObject({
|
||||
updatedInput: { answers: { [questionId]: answer } }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns arrays for multi-select and preserves mixed single and Other answers', () => {
|
||||
const multiQuestion = 'Which targets?'
|
||||
const singleQuestion = 'Which mode?'
|
||||
const otherQuestion = 'Where should it run?'
|
||||
const prompt: ClaudePendingPrompt = {
|
||||
requestId: 'question-1',
|
||||
promptKey: 'question-1',
|
||||
toolUseId: 'tool-1',
|
||||
toolName: 'AskUserQuestion',
|
||||
kind: 'question',
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
question: multiQuestion,
|
||||
multiSelect: true,
|
||||
options: [{ label: 'frontend' }, { label: 'backend' }]
|
||||
},
|
||||
{
|
||||
question: singleQuestion,
|
||||
options: [{ label: 'fast' }, { label: 'safe' }]
|
||||
},
|
||||
{ question: otherQuestion, options: [] }
|
||||
]
|
||||
},
|
||||
suggestions: [],
|
||||
questionIds: [multiQuestion, singleQuestion, otherQuestion],
|
||||
answers: new Map(),
|
||||
settle: () => {}
|
||||
}
|
||||
const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]!
|
||||
const questions = item.body.questions!
|
||||
const encoded = encodeAgentSessionQuestionAnswers([
|
||||
{
|
||||
questionId: 'q1',
|
||||
optionIds: [questions[0]!.options[0]!.id, questions[0]!.options[1]!.id]
|
||||
},
|
||||
{ questionId: 'q2', optionIds: [questions[1]!.options[1]!.id] },
|
||||
{ questionId: 'q3', optionIds: [], other: 'remote host' }
|
||||
])
|
||||
|
||||
expect(applyClaudePromptAnswer({ prompt }, encoded)).toMatchObject({
|
||||
updatedInput: {
|
||||
answers: {
|
||||
[multiQuestion]: ['frontend', 'backend'],
|
||||
[singleQuestion]: 'safe',
|
||||
[otherQuestion]: 'remote host'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
AgentJournalApprovalItem,
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalPromptOption,
|
||||
AgentJournalQuestion,
|
||||
AgentJournalQuestionItem
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import {
|
||||
boundInlineText,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from '../native-chat/agent-session-journal/journal-payload-bounds'
|
||||
import { claudeRecord, claudeText } from './claude-structured-item-translation'
|
||||
import {
|
||||
CLAUDE_APPROVAL_DECISIONS,
|
||||
encodeClaudeQuestionOptionId,
|
||||
type ClaudeApprovalDecision,
|
||||
type ClaudePendingPrompt
|
||||
} from './claude-structured-prompt-replies'
|
||||
|
||||
const APPROVAL_LABELS: Record<ClaudeApprovalDecision, string> = {
|
||||
allow: 'Allow',
|
||||
allowForSession: 'Allow for this session',
|
||||
deny: 'Deny',
|
||||
cancel: 'Stop'
|
||||
}
|
||||
|
||||
const PENDING = {
|
||||
state: 'pending',
|
||||
selectedOptionId: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null
|
||||
} as const
|
||||
|
||||
export function claudePromptIdentity(input: {
|
||||
sessionId: string
|
||||
promptKey: string
|
||||
questionId?: string
|
||||
}): AgentJournalItemIdentity {
|
||||
const suffix = input.questionId ? `:${input.questionId}` : ''
|
||||
return {
|
||||
provider: 'orca',
|
||||
clientMessageId: `claude-prompt:${input.sessionId}:${input.promptKey}${suffix}`
|
||||
}
|
||||
}
|
||||
|
||||
export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem {
|
||||
const serialized = JSON.stringify(prompt.input)
|
||||
return {
|
||||
kind: 'approval',
|
||||
title: `Allow ${prompt.toolName}?`,
|
||||
detail: serialized ? boundInlineText(serialized, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text : null,
|
||||
options: CLAUDE_APPROVAL_DECISIONS.map((decision) => ({
|
||||
id: decision,
|
||||
label: APPROVAL_LABELS[decision]
|
||||
})),
|
||||
resolution: { ...PENDING }
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudeQuestionItem = {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: AgentJournalQuestionItem
|
||||
}
|
||||
|
||||
function questionOptions(
|
||||
question: Record<string, unknown>,
|
||||
questionAddress: string
|
||||
): AgentJournalPromptOption[] {
|
||||
if (!Array.isArray(question.options)) {
|
||||
return []
|
||||
}
|
||||
return question.options.flatMap((value, index) => {
|
||||
const option = claudeRecord(value)
|
||||
const label = claudeText(option?.label)
|
||||
const description = claudeText(option?.description)
|
||||
return label
|
||||
? [
|
||||
{
|
||||
id: encodeClaudeQuestionOptionId(questionAddress, `choice-${index + 1}`),
|
||||
label,
|
||||
...(description ? { description } : {})
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
export function claudeQuestionItems(input: {
|
||||
sessionId: string
|
||||
prompt: ClaudePendingPrompt
|
||||
}): ClaudeQuestionItem[] {
|
||||
const values = Array.isArray(input.prompt.input.questions) ? input.prompt.input.questions : []
|
||||
const questions = values.flatMap((value, index): AgentJournalQuestion[] => {
|
||||
const question = claudeRecord(value)
|
||||
const questionAddress = `q${index + 1}`
|
||||
const text = claudeText(question?.question) ?? claudeText(question?.header)
|
||||
const header = claudeText(question?.header)
|
||||
return question && input.prompt.questionIds[index] && text
|
||||
? [
|
||||
{
|
||||
id: questionAddress,
|
||||
question: text,
|
||||
...(header ? { header } : {}),
|
||||
options: questionOptions(question, questionAddress),
|
||||
multiSelect: question.multiSelect === true,
|
||||
freeTextQuestionId: questionAddress
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
if (questions.length === 0) {
|
||||
return []
|
||||
}
|
||||
const legacyCompatible = questions.length === 1 && questions[0]?.multiSelect === false
|
||||
const first = questions[0]!
|
||||
return [
|
||||
{
|
||||
identity: claudePromptIdentity({
|
||||
sessionId: input.sessionId,
|
||||
promptKey: input.prompt.promptKey
|
||||
}),
|
||||
body: {
|
||||
kind: 'question',
|
||||
question: legacyCompatible
|
||||
? first.question
|
||||
: `${questions.length} grouped question${questions.length === 1 ? '' : 's'} from Claude`,
|
||||
options: legacyCompatible ? first.options : [],
|
||||
...(legacyCompatible ? { freeTextQuestionId: first.freeTextQuestionId } : {}),
|
||||
questions,
|
||||
resolution: { ...PENDING }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { decodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer'
|
||||
|
||||
export const CLAUDE_APPROVAL_DECISIONS = ['allow', 'allowForSession', 'deny', 'cancel'] as const
|
||||
export type ClaudeApprovalDecision = (typeof CLAUDE_APPROVAL_DECISIONS)[number]
|
||||
|
||||
/** Settles the SDK's `canUseTool` promise; `null` is the SDK's "no response written" sentinel. */
|
||||
export type ClaudePromptSettle = (response: Record<string, unknown> | null) => void
|
||||
|
||||
export type ClaudePendingPrompt = {
|
||||
requestId: string
|
||||
promptKey: string
|
||||
toolUseId: string
|
||||
toolName: string
|
||||
kind: 'approval' | 'question'
|
||||
input: Record<string, unknown>
|
||||
suggestions: unknown[]
|
||||
questionIds: readonly string[]
|
||||
answers: Map<string, string | readonly string[]>
|
||||
settle: ClaudePromptSettle
|
||||
}
|
||||
|
||||
export type ClaudePromptRegistration = {
|
||||
requestId: string
|
||||
toolName: string
|
||||
toolUseId: string
|
||||
input: Record<string, unknown>
|
||||
suggestions: unknown[]
|
||||
settle: ClaudePromptSettle
|
||||
}
|
||||
|
||||
type PromptBinding = {
|
||||
address: string
|
||||
questionId?: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
||||
}
|
||||
|
||||
function questionsFrom(input: Record<string, unknown>): Record<string, unknown>[] {
|
||||
return Array.isArray(input.questions) ? input.questions.filter(isRecord) : []
|
||||
}
|
||||
|
||||
function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null {
|
||||
const match = /^q([1-9]\d*)$/.exec(address)
|
||||
const index = match ? Number(match[1]) - 1 : -1
|
||||
return index >= 0 ? (prompt.questionIds[index] ?? null) : null
|
||||
}
|
||||
|
||||
function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionId: string): string {
|
||||
const decoded = decodeClaudeQuestionOptionId(optionId)
|
||||
if (!decoded) {
|
||||
return optionId
|
||||
}
|
||||
const questionIndex = prompt.questionIds.indexOf(questionId)
|
||||
if (questionIndex === -1) {
|
||||
return optionId
|
||||
}
|
||||
const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer)
|
||||
const optionIndex = choice ? Number(choice[1]) - 1 : -1
|
||||
const question = questionsFrom(prompt.input)[questionIndex]
|
||||
const options = Array.isArray(question?.options) ? question.options : []
|
||||
const option = options[optionIndex]
|
||||
const label = isRecord(option) ? readString(option.label) : null
|
||||
if (decoded.questionId === `q${questionIndex + 1}` && label) {
|
||||
return label
|
||||
}
|
||||
if (decoded.questionId === `q${questionIndex + 1}`) {
|
||||
return decoded.answer
|
||||
}
|
||||
const legacyChoice = options.some(
|
||||
(candidate) => isRecord(candidate) && readString(candidate.label) === decoded.answer
|
||||
)
|
||||
return decoded.questionId === questionId && (legacyChoice || decoded.answer.trim().length > 0)
|
||||
? decoded.answer
|
||||
: optionId
|
||||
}
|
||||
|
||||
function questionId(question: Record<string, unknown>, index: number): string {
|
||||
return readString(question.question) ?? readString(question.header) ?? `question-${index + 1}`
|
||||
}
|
||||
|
||||
export function encodeClaudeQuestionOptionId(questionId: string, answer: string): string {
|
||||
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
|
||||
}
|
||||
|
||||
export function decodeClaudeQuestionOptionId(
|
||||
optionId: string
|
||||
): { questionId: string; answer: string } | null {
|
||||
const separator = optionId.indexOf(':')
|
||||
if (separator <= 0) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return {
|
||||
questionId: decodeURIComponent(optionId.slice(0, separator)),
|
||||
answer: decodeURIComponent(optionId.slice(separator + 1))
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudePromptRegistry {
|
||||
private readonly prompts = new Map<string, ClaudePendingPrompt>()
|
||||
private readonly journalBindings = new Map<string, PromptBinding>()
|
||||
|
||||
register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null {
|
||||
const toolUseId = readString(registration.toolUseId)
|
||||
const toolName = readString(registration.toolName)
|
||||
const input = isRecord(registration.input) ? registration.input : null
|
||||
if (!toolUseId || !toolName || !input) {
|
||||
return null
|
||||
}
|
||||
const questions = toolName === 'AskUserQuestion' ? questionsFrom(input) : []
|
||||
const prompt: ClaudePendingPrompt = {
|
||||
requestId: registration.requestId,
|
||||
promptKey: registration.requestId,
|
||||
toolUseId,
|
||||
toolName,
|
||||
kind: questions.length > 0 ? 'question' : 'approval',
|
||||
input,
|
||||
suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [],
|
||||
questionIds: questions.map(questionId),
|
||||
answers: new Map(),
|
||||
settle: registration.settle
|
||||
}
|
||||
this.prompts.set(prompt.promptKey, prompt)
|
||||
return prompt
|
||||
}
|
||||
|
||||
/** True only if the prompt was still pending; lets an abort and an answer race settle once. */
|
||||
forgetIfPending(prompt: ClaudePendingPrompt): boolean {
|
||||
if (!this.prompts.has(prompt.promptKey)) {
|
||||
return false
|
||||
}
|
||||
this.forget(prompt)
|
||||
return true
|
||||
}
|
||||
|
||||
bindJournalItemId(journalItemId: string, promptKey: string, questionIdForItem?: string): void {
|
||||
this.journalBindings.set(journalItemId, {
|
||||
address: promptKey,
|
||||
...(questionIdForItem ? { questionId: questionIdForItem } : {})
|
||||
})
|
||||
}
|
||||
|
||||
find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null {
|
||||
const binding = this.journalBindings.get(itemId)
|
||||
const prompt = this.prompts.get(binding?.address ?? itemId)
|
||||
return prompt
|
||||
? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) }
|
||||
: null
|
||||
}
|
||||
|
||||
cancel(requestId: string): ClaudePendingPrompt | null {
|
||||
const prompt = this.prompts.get(requestId) ?? null
|
||||
if (prompt) {
|
||||
this.forget(prompt)
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
forget(prompt: ClaudePendingPrompt): void {
|
||||
this.prompts.delete(prompt.promptKey)
|
||||
for (const [itemId, binding] of this.journalBindings) {
|
||||
if (binding.address === prompt.promptKey) {
|
||||
this.journalBindings.delete(itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(): ClaudePendingPrompt[] {
|
||||
const pending = [...this.prompts.values()]
|
||||
this.prompts.clear()
|
||||
this.journalBindings.clear()
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Record<string, unknown> {
|
||||
if (!(CLAUDE_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) {
|
||||
throw new Error(`${optionId} is not a Claude approval decision`)
|
||||
}
|
||||
const decision = optionId as ClaudeApprovalDecision
|
||||
if (decision === 'allow' || decision === 'allowForSession') {
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: prompt.input,
|
||||
...(decision === 'allowForSession' && prompt.suggestions.length > 0
|
||||
? { updatedPermissions: prompt.suggestions }
|
||||
: {}),
|
||||
toolUseID: prompt.toolUseId
|
||||
}
|
||||
}
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: decision === 'cancel' ? 'User stopped this turn.' : 'User denied this action.',
|
||||
...(decision === 'cancel' ? { interrupt: true } : {}),
|
||||
toolUseID: prompt.toolUseId
|
||||
}
|
||||
}
|
||||
|
||||
function questionResponse(
|
||||
prompt: ClaudePendingPrompt,
|
||||
optionId: string,
|
||||
boundQuestionId?: string
|
||||
): Record<string, unknown> | null {
|
||||
const decoded = decodeClaudeQuestionOptionId(optionId)
|
||||
const decodedQuestionId = decoded
|
||||
? (questionIdFromAddress(prompt, decoded.questionId) ??
|
||||
(prompt.questionIds.includes(decoded.questionId) ? decoded.questionId : null))
|
||||
: null
|
||||
const selectedQuestionId =
|
||||
boundQuestionId ??
|
||||
decodedQuestionId ??
|
||||
(prompt.questionIds.length === 1 ? prompt.questionIds[0] : null)
|
||||
if (!selectedQuestionId || !prompt.questionIds.includes(selectedQuestionId)) {
|
||||
throw new Error(`${optionId} does not name a question on Claude prompt ${prompt.promptKey}`)
|
||||
}
|
||||
const answer = questionAnswer(prompt, selectedQuestionId, optionId)
|
||||
prompt.answers.set(selectedQuestionId, answer)
|
||||
if (prompt.questionIds.some((id) => !prompt.answers.has(id))) {
|
||||
return null
|
||||
}
|
||||
const answers: Record<string, string | readonly string[]> = {}
|
||||
for (const id of prompt.questionIds) {
|
||||
answers[id] = prompt.answers.get(id) as string
|
||||
}
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: { ...prompt.input, answers },
|
||||
toolUseID: prompt.toolUseId
|
||||
}
|
||||
}
|
||||
|
||||
function groupedQuestionResponse(
|
||||
prompt: ClaudePendingPrompt,
|
||||
optionId: string
|
||||
): Record<string, unknown> | null {
|
||||
const grouped = decodeAgentSessionQuestionAnswers(optionId)
|
||||
if (!grouped) {
|
||||
return null
|
||||
}
|
||||
const questions = questionsFrom(prompt.input)
|
||||
if (grouped.length !== prompt.questionIds.length) {
|
||||
throw new Error(`Grouped answer does not match Claude prompt ${prompt.promptKey}`)
|
||||
}
|
||||
const answers: Record<string, string | readonly string[]> = {}
|
||||
for (let index = 0; index < questions.length; index += 1) {
|
||||
const question = questions[index]!
|
||||
const providerQuestionId = prompt.questionIds[index]
|
||||
const answer = grouped.find((entry) => entry.questionId === `q${index + 1}`)
|
||||
if (!providerQuestionId || !answer) {
|
||||
throw new Error(`Grouped answer does not name question ${index + 1}`)
|
||||
}
|
||||
const selected = answer.optionIds.map((selectedId) =>
|
||||
questionAnswer(prompt, providerQuestionId, selectedId)
|
||||
)
|
||||
const other = answer.other?.trim()
|
||||
if (question.multiSelect === true) {
|
||||
const values = [...selected, ...(other ? [other] : [])]
|
||||
if (values.length === 0) {
|
||||
throw new Error(`Grouped answer leaves question ${index + 1} empty`)
|
||||
}
|
||||
answers[providerQuestionId] = values
|
||||
} else {
|
||||
const value = other || selected[0]
|
||||
if (!value || selected.length > 1) {
|
||||
throw new Error(`Grouped answer is invalid for question ${index + 1}`)
|
||||
}
|
||||
answers[providerQuestionId] = value
|
||||
}
|
||||
}
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: { ...prompt.input, answers },
|
||||
toolUseID: prompt.toolUseId
|
||||
}
|
||||
}
|
||||
|
||||
export function applyClaudePromptAnswer(
|
||||
found: { prompt: ClaudePendingPrompt; questionId?: string },
|
||||
optionId: string
|
||||
): Record<string, unknown> | null {
|
||||
if (found.prompt.kind === 'approval') {
|
||||
return approvalResponse(found.prompt, optionId)
|
||||
}
|
||||
return (
|
||||
groupedQuestionResponse(found.prompt, optionId) ??
|
||||
questionResponse(found.prompt, optionId, found.questionId)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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 {
|
||||
AgentJournalItemBody,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory'
|
||||
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' }
|
||||
}
|
||||
|
||||
let root = ''
|
||||
|
||||
function message(
|
||||
role: 'assistant' | 'user',
|
||||
uuid: string,
|
||||
content: unknown[]
|
||||
): ClaudeStructuredSessionEvent {
|
||||
return {
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: role,
|
||||
uuid,
|
||||
session_id: 'provider-1',
|
||||
message: { role, content }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-claude-provider-fallback-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Claude provider fallback', () => {
|
||||
it('drops suppressed init frames instead of dereferencing a null translation', () => {
|
||||
const items: { identity: unknown; body: AgentJournalItemBody }[] = []
|
||||
const sink = {
|
||||
appendItem: (identity: unknown, body: AgentJournalItemBody) => {
|
||||
items.push({ identity, body })
|
||||
},
|
||||
appendTombstone: vi.fn(),
|
||||
publish: vi.fn()
|
||||
}
|
||||
const translator = createClaudeJournalTranslator({ sink })
|
||||
const initEvent: ClaudeStructuredSessionEvent = {
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
session_id: 'provider-1',
|
||||
uuid: 'init-1'
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => translator.handle(initEvent)).not.toThrow()
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps provider-fallback rows distinct across acquisitions', async () => {
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
now: () => 1_700_000_000_000,
|
||||
mintEpoch: () => 'epoch-1'
|
||||
})
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink()
|
||||
deferred.bind({
|
||||
journal,
|
||||
fence: 1,
|
||||
publish: vi.fn()
|
||||
})
|
||||
|
||||
const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' })
|
||||
const second = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '2' })
|
||||
|
||||
first.handle(message('assistant', 'assistant-1', [{ type: 'future_event', message: 'first' }]))
|
||||
await deferred.drained()
|
||||
second.handle(
|
||||
message('assistant', 'assistant-2', [{ type: 'future_event', message: 'second' }])
|
||||
)
|
||||
await deferred.drained()
|
||||
|
||||
const fallbackRows = journal
|
||||
.snapshot()
|
||||
.items.filter(
|
||||
(item) =>
|
||||
item.body.kind === 'status' &&
|
||||
item.body.providerFrame?.kind === 'message:assistant:content:future_event'
|
||||
)
|
||||
|
||||
expect(fallbackRows).toHaveLength(2)
|
||||
expect(fallbackRows.map(statusText)).toEqual(['first', 'second'])
|
||||
})
|
||||
})
|
||||
|
||||
function statusText(row: { body: AgentJournalItemBody }): string {
|
||||
if (row.body.kind !== 'status') {
|
||||
throw new Error('expected status row')
|
||||
}
|
||||
return row.body.text
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import {
|
||||
boundInlineText,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from '../native-chat/agent-session-journal/journal-payload-bounds'
|
||||
import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema'
|
||||
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
|
||||
import { claudeRecord, claudeText } from './claude-structured-item-translation'
|
||||
|
||||
export function claudeProviderFrameKind(message: Record<string, unknown>): string {
|
||||
const type = claudeText(message.type) ?? 'unknown'
|
||||
const subtype = claudeText(message.subtype)
|
||||
const eventType = claudeText(claudeRecord(message.event)?.type)
|
||||
return ['message', type, subtype ?? eventType].filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
const SETTLED_RESULT_KINDS: ReadonlySet<string> = new Set(
|
||||
CLAUDE_STREAM_JSON_FRAME_KINDS.filter((kind) => kind.startsWith('message:result:'))
|
||||
)
|
||||
|
||||
/** A catalogued result subtype is the turn-complete signal the translator settles
|
||||
* itself; only an unmodeled subtype still needs the provider-fallback row. */
|
||||
export function isSettledClaudeResultKind(kind: string): boolean {
|
||||
return SETTLED_RESULT_KINDS.has(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure a result frame carries that the turn's own frames never showed.
|
||||
*
|
||||
* Suppression is by meaning, not by kind. The SDK models an API failure as a
|
||||
* SUCCESS-subtype result whose `result` string IS the error text and which has
|
||||
* no assistant frame behind it, so keying on the subtype tombstones the turn and
|
||||
* shows the user a completed, empty reply. A turn the user aborted is the
|
||||
* opposite: its interrupt frame already says so, and the diagnostic in `errors`
|
||||
* would only be noise.
|
||||
*/
|
||||
export function claudeResultFailure(
|
||||
message: Record<string, unknown>
|
||||
): { text: string | null } | null {
|
||||
if (message.is_error !== true) {
|
||||
return null
|
||||
}
|
||||
const terminalReason = claudeText(message.terminal_reason)
|
||||
if (terminalReason === 'aborted_streaming' || terminalReason === 'aborted_tools') {
|
||||
return null
|
||||
}
|
||||
const result = claudeText(message.result)?.trim()
|
||||
if (result) {
|
||||
return { text: result }
|
||||
}
|
||||
const errors = Array.isArray(message.errors)
|
||||
? message.errors.flatMap((entry) => {
|
||||
const text = claudeText(entry)?.trim()
|
||||
return text ? [text] : []
|
||||
})
|
||||
: []
|
||||
// Nothing readable to lead with, but a reported failure still gets its row.
|
||||
return { text: errors.length > 0 ? errors.join('\n') : null }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a message part that Orca cannot render says for itself. The kinds under
|
||||
* `message:<role>:content:*` are synthesised from whatever `part.type` the CLI
|
||||
* sends, so they can never be catalogued ahead of time; printing one is leaking
|
||||
* wire vocabulary at a user who cannot act on it. The frame stays on the row's
|
||||
* disclosure, so nothing is dropped and the next reader can still name it.
|
||||
*/
|
||||
export const CLAUDE_UNRENDERABLE_CONTENT_TEXT = 'Claude sent content Orca cannot display yet'
|
||||
|
||||
export function isModeledClaudeContent(value: unknown): boolean {
|
||||
const part = claudeRecord(value)
|
||||
if (!part) {
|
||||
return false
|
||||
}
|
||||
if (part.type === 'text') {
|
||||
return claudeText(part.text) !== null
|
||||
}
|
||||
if (part.type === 'image') {
|
||||
const source = claudeRecord(part.source)
|
||||
if (source?.type === 'url') {
|
||||
return claudeText(source.url) !== null
|
||||
}
|
||||
// A local attachment is replayed as the base64 (or file) source Orca itself
|
||||
// sent, so it is content we recognise -- not an unknown part to surface.
|
||||
return source?.type === 'base64' || source?.type === 'file'
|
||||
}
|
||||
if (part.type === 'tool_use') {
|
||||
return claudeText(part.id) !== null && claudeText(part.name) !== null
|
||||
}
|
||||
if (part.type === 'tool_result') {
|
||||
return claudeText(part.tool_use_id) !== null
|
||||
}
|
||||
// Redacted thinking arrives as an empty string plus a signature.
|
||||
return part.type === 'thinking' || part.type === 'redacted_thinking'
|
||||
}
|
||||
|
||||
export function createClaudeProviderFrameFallback(
|
||||
sink: StructuredAgentSessionEventSink,
|
||||
acquisitionId: string
|
||||
): {
|
||||
/** `displayText` leads the row when Claude knows the sentence the frame itself does not name. */
|
||||
append: (kind: string, payload: unknown, displayText?: string | null) => void
|
||||
} {
|
||||
let sequence = 0
|
||||
return {
|
||||
append: (kind, payload, displayText) => {
|
||||
sequence += 1
|
||||
const translated = unhandledProviderFrameJournalItem('claude', kind, payload)
|
||||
if (!translated) {
|
||||
return
|
||||
}
|
||||
const bounded = displayText
|
||||
? boundInlineText(displayText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
|
||||
: null
|
||||
sink.appendItem(
|
||||
{
|
||||
provider: 'orca',
|
||||
clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}`
|
||||
},
|
||||
bounded ? { ...translated.body, text: bounded } : translated.body
|
||||
)
|
||||
sink.publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { basename, join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
|
||||
import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
|
||||
import {
|
||||
ClaudeStructuredSessionAdapter,
|
||||
type ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-adapter'
|
||||
|
||||
const command = resolveClaudeCommand()
|
||||
const versionLaunch = getSpawnArgsForWindows(command, ['--version'])
|
||||
const realClaudeAvailable =
|
||||
spawnSync(versionLaunch.spawnCmd, versionLaunch.spawnArgs, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
timeout: 5_000
|
||||
}).status === 0
|
||||
const authStatusLaunch = getSpawnArgsForWindows(command, ['auth', 'status', '--json'])
|
||||
/** The CLI's own account report — the only source of truth for where it writes that
|
||||
* is not derived from Orca's own path expressions. */
|
||||
const realClaudeAuthStatus = (() => {
|
||||
if (!realClaudeAvailable) {
|
||||
return null
|
||||
}
|
||||
const result = spawnSync(authStatusLaunch.spawnCmd, authStatusLaunch.spawnArgs, {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 5_000
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout) as { loggedIn?: boolean; projectsDirectory?: string }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true
|
||||
|
||||
function realAdapter(
|
||||
providerSessionId: string,
|
||||
claudeConfigDir: string,
|
||||
events: ClaudeStructuredSessionEvent[] = []
|
||||
): ClaudeStructuredSessionAdapter {
|
||||
return new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
pathToClaudeCodeExecutable: command,
|
||||
options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId },
|
||||
cwd: process.cwd(),
|
||||
claudeConfigDir,
|
||||
providerSessionId,
|
||||
resumeLeafUuid: null,
|
||||
resumed: false
|
||||
}),
|
||||
onEvent: (event) => events.push(event),
|
||||
readProcessStartTime: async () => 1,
|
||||
now: () => 2,
|
||||
initTimeoutMs: 5_000
|
||||
})
|
||||
}
|
||||
|
||||
function identity(providerSessionId: string): AgentSessionJournalIdentity {
|
||||
return {
|
||||
sessionId: 'real-cli-handshake',
|
||||
workspaceId: 'real-cli-workspace',
|
||||
hostId: 'local',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null }
|
||||
}
|
||||
}
|
||||
|
||||
/** The CLI flushes its transcript on its own schedule; poll rather than race it. */
|
||||
async function waitForResolvedTranscript(
|
||||
providerSessionId: string,
|
||||
timeoutMs = 15_000
|
||||
): Promise<string | null> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
// No options: the exact call transcript-read-cache.ts makes for mobile.
|
||||
const resolved = await resolveSessionFilePath('claude', providerSessionId)
|
||||
if (resolved || Date.now() >= deadline) {
|
||||
return resolved
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () => {
|
||||
it.skipIf(!realClaudeAuthenticated)(
|
||||
'proves a pre-minted session before the first user message',
|
||||
async () => {
|
||||
const providerSessionId = randomUUID()
|
||||
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = realAdapter(providerSessionId, claudeConfigDir, events)
|
||||
|
||||
try {
|
||||
const acquisition = await adapter.acquire({
|
||||
identity: identity(providerSessionId),
|
||||
fence: 1,
|
||||
spawnToken: 'real-cli'
|
||||
})
|
||||
const observedSubtypes = events.flatMap((event) =>
|
||||
event.type === 'message' ? [event.message.subtype] : []
|
||||
)
|
||||
|
||||
expect(acquisition.link.handle).toMatchObject({
|
||||
provider: 'claude',
|
||||
sessionId: providerSessionId,
|
||||
// Init/SessionStart UUIDs are protocol frames, not resumable
|
||||
// main-transcript leaves; no cursor exists before the first user turn.
|
||||
leafUuid: null
|
||||
})
|
||||
expect(observedSubtypes).toContain('hook_started')
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
// Unit tests can only pin the shape we read, which is exactly how the blank
|
||||
// Effort pill survived every gate: the fixture invented an `effortLevel` on a
|
||||
// frame the CLI does not send. This asserts both halves against the live
|
||||
// binary — that get_settings reports the effort, and that init does not.
|
||||
it.skipIf(!realClaudeAuthenticated)(
|
||||
'reports the current effort through get_settings and never on the init frame',
|
||||
async () => {
|
||||
const providerSessionId = randomUUID()
|
||||
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = realAdapter(providerSessionId, claudeConfigDir, events)
|
||||
|
||||
try {
|
||||
await adapter.acquire({
|
||||
identity: identity(providerSessionId),
|
||||
fence: 1,
|
||||
spawnToken: 'real-cli-effort'
|
||||
})
|
||||
const published = events.flatMap((event) =>
|
||||
event.type === 'message' ? [event.message] : []
|
||||
)
|
||||
const options = await adapter.readOptions({ sessionId: 'real-cli-handshake', fence: 1 })
|
||||
|
||||
expect(published.length).toBeGreaterThan(0)
|
||||
// Not just the init frame: no frame the CLI publishes carries an effort
|
||||
// at all. Goes red the day one does, which is when the simpler fix
|
||||
// becomes available. Which frame proves the session varies by host, so
|
||||
// this asserts over all of them rather than picking one.
|
||||
expect(published.filter((frame) => 'effortLevel' in frame)).toEqual([])
|
||||
// Goes red if `effective.effortLevel` is renamed or dropped, which no
|
||||
// fixture-backed test can see.
|
||||
expect(options.current.effort).toEqual(expect.any(String))
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
},
|
||||
15_000
|
||||
)
|
||||
|
||||
// Mobile native chat never reads the structured journal — it reads the CLI's own
|
||||
// transcript through native-chat/session-file-resolver.ts. So this resolves the way
|
||||
// transcript-read-cache.ts:104 does, with NO root override, and checks the answer
|
||||
// against the root the CLI itself reports. Deriving the expected root from Orca's own
|
||||
// `CLAUDE_CONFIG_DIR || ~/.claude` expression — the same one the code under test uses —
|
||||
// would move both sides together and stay green in exactly the environment that
|
||||
// blacks mobile out.
|
||||
// The turn is what creates the file: an init-only handshake writes nothing.
|
||||
it.skipIf(!realClaudeAuthenticated || !realClaudeAuthStatus?.projectsDirectory)(
|
||||
'writes its transcript where the mobile session-file resolver looks for it',
|
||||
async () => {
|
||||
const providerSessionId = randomUUID()
|
||||
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
|
||||
const adapter = realAdapter(providerSessionId, claudeConfigDir)
|
||||
const cliProjectsDir = realClaudeAuthStatus?.projectsDirectory as string
|
||||
|
||||
let transcriptPath: string | null = null
|
||||
try {
|
||||
await adapter.acquire({
|
||||
identity: identity(providerSessionId),
|
||||
fence: 1,
|
||||
spawnToken: 'real-cli-transcript'
|
||||
})
|
||||
await adapter.dispatch({
|
||||
sessionId: 'real-cli-handshake',
|
||||
clientMessageId: 'real-cli-transcript-1',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
|
||||
fence: 1
|
||||
})
|
||||
transcriptPath = await waitForResolvedTranscript(providerSessionId)
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
|
||||
expect(transcriptPath).not.toBeNull()
|
||||
expect(basename(transcriptPath ?? '')).toBe(`${providerSessionId}.jsonl`)
|
||||
// `<the root the CLI reports>/<project slug>/<provider session id>.jsonl`
|
||||
expect(relative(cliProjectsDir, transcriptPath ?? '').split(/[\\/]/)).toHaveLength(2)
|
||||
// And the pinned account home is that same root, so the host-side leaf recovery
|
||||
// (structured-claude-runtime-adapter.ts:64) and mobile agree.
|
||||
expect(join(claudeConfigDir, 'projects')).toBe(cliProjectsDir)
|
||||
},
|
||||
45_000
|
||||
)
|
||||
|
||||
// The model half of the same lesson: a fixture can only pin the shape we read.
|
||||
// set_model answers success for a model it never resolves — a nonexistent id is
|
||||
// accepted and only fails once a turn runs — so the CLI's own report is the only
|
||||
// adoption evidence, and it arrives on the init frame that opens each turn. This
|
||||
// asserts that frame carries the resolved model against the live binary; it goes
|
||||
// red the day the CLI stops reporting it, which is the day the confirmation
|
||||
// silently degrades to echoing back whatever Orca sent.
|
||||
it.skipIf(!realClaudeAuthenticated)(
|
||||
'reports the model it adopted on the init frame that opens each turn',
|
||||
async () => {
|
||||
const providerSessionId = randomUUID()
|
||||
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = realAdapter(providerSessionId, claudeConfigDir, events)
|
||||
|
||||
try {
|
||||
await adapter.acquire({
|
||||
identity: identity(providerSessionId),
|
||||
fence: 1,
|
||||
spawnToken: 'real-cli-model'
|
||||
})
|
||||
await adapter.setOption({
|
||||
sessionId: 'real-cli-handshake',
|
||||
key: 'model',
|
||||
value: 'haiku',
|
||||
fence: 1
|
||||
})
|
||||
const before = events.length
|
||||
await adapter.dispatch({
|
||||
sessionId: 'real-cli-handshake',
|
||||
clientMessageId: 'real-cli-model-1',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Say ok' }] },
|
||||
fence: 1
|
||||
})
|
||||
const deadline = Date.now() + 60_000
|
||||
let frames: Record<string, unknown>[] = []
|
||||
for (;;) {
|
||||
frames = events
|
||||
.slice(before)
|
||||
.flatMap((event) =>
|
||||
event.type === 'message' &&
|
||||
event.message.type === 'system' &&
|
||||
event.message.subtype === 'init'
|
||||
? [event.message]
|
||||
: []
|
||||
)
|
||||
if (frames.length > 0 || Date.now() >= deadline) {
|
||||
break
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
expect(frames).not.toHaveLength(0)
|
||||
// Both halves: the field exists, and it names the model the picker asked
|
||||
// for in the catalog's resolved shape rather than the id Orca sent.
|
||||
expect(frames[0]?.model).toEqual(expect.any(String))
|
||||
expect(frames[0]?.model).toBe('claude-haiku-4-5-20251001')
|
||||
await expect(
|
||||
adapter.readOptions({ sessionId: 'real-cli-handshake', fence: 1 })
|
||||
).resolves.toMatchObject({ current: { model: 'haiku' } })
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
},
|
||||
90_000
|
||||
)
|
||||
|
||||
it('turns a real silent unauthenticated startup into sign-in guidance', async () => {
|
||||
const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-'))
|
||||
const providerSessionId = randomUUID()
|
||||
const adapter = realAdapter(providerSessionId, claudeConfigDir)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
adapter.acquire({
|
||||
identity: identity(providerSessionId),
|
||||
fence: 1,
|
||||
spawnToken: 'real-cli-no-auth'
|
||||
})
|
||||
).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s)
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
await rm(claudeConfigDir, { recursive: true, force: true })
|
||||
}
|
||||
}, 10_000)
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type {
|
||||
ClaudeStreamJsonConnection,
|
||||
openClaudeStreamJsonConnection
|
||||
} from './claude-stream-json-connection'
|
||||
import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter'
|
||||
|
||||
const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a'
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-processless',
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'opaque', agent: 'claude', value: 'pending' }
|
||||
}
|
||||
|
||||
describe('Claude structured processless acquisition', () => {
|
||||
it('classifies pre-pid error and close as processless with idempotent cleanup', async () => {
|
||||
const fault = new Error('spawn claude ENOENT')
|
||||
const close = vi.fn(async () => true)
|
||||
const openConnection: typeof openClaudeStreamJsonConnection = async (
|
||||
_launch,
|
||||
handlers = {}
|
||||
) => {
|
||||
const connection: ClaudeStreamJsonConnection = {
|
||||
pid: undefined,
|
||||
closed: true,
|
||||
exitVerdict: { root: 'processless', tree: 'exited' },
|
||||
initializationResult: async () => {
|
||||
handlers.onFault?.(fault)
|
||||
throw fault
|
||||
},
|
||||
getSettings: async () => ({}),
|
||||
supportedModels: async () => [],
|
||||
interrupt: async () => undefined,
|
||||
cancelAsyncMessage: async () => {},
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
applyFlagSettings: async () => {},
|
||||
send: async () => {},
|
||||
close
|
||||
}
|
||||
return connection
|
||||
}
|
||||
const adapter = new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
pathToClaudeCodeExecutable: 'claude',
|
||||
options: {},
|
||||
cwd: '/work/repo',
|
||||
claudeConfigDir: '/accounts/claude',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
resumeLeafUuid: null,
|
||||
resumed: false
|
||||
}),
|
||||
openConnection
|
||||
})
|
||||
|
||||
const error = await adapter
|
||||
.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' })
|
||||
.catch((cause: unknown) => cause)
|
||||
|
||||
expect(error).toBeInstanceOf(AgentSessionPreSpawnError)
|
||||
expect(error).toMatchObject({ message: fault.message })
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
await expect(adapter.releaseAcquisition({ sessionId: IDENTITY.sessionId })).resolves.toBe(true)
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
import {
|
||||
AgentSessionAcquisitionExitUnprovenError,
|
||||
AgentSessionPreSpawnError
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type {
|
||||
AgentSessionAcquisition,
|
||||
StructuredAgentSessionAcquireInput
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment'
|
||||
import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate'
|
||||
import { openClaudeStreamJsonConnection } from './claude-stream-json-connection'
|
||||
import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control'
|
||||
import { resolveClaudeReplayWaiter } from './claude-structured-dispatch'
|
||||
import {
|
||||
claudeAuthDiagnostic,
|
||||
readClaudeCapabilities,
|
||||
readClaudeFrameString,
|
||||
readClaudeInit,
|
||||
readClaudeModels
|
||||
} from './claude-structured-init-proof'
|
||||
import {
|
||||
createClaudeInitDeadline,
|
||||
requestClaudeInitialization
|
||||
} from './claude-structured-init-deadline'
|
||||
import { claudeConfigDirEnvPatch } from './claude-config-dir-pin'
|
||||
import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity'
|
||||
import {
|
||||
restoreClaudeStructuredSessionOptions,
|
||||
restoredClaudeStructuredSessionOptions
|
||||
} from './claude-structured-options'
|
||||
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation'
|
||||
import { readClaudeSettingsEffort } from './claude-structured-session-options'
|
||||
import { createClaudeSessionPublication } from './claude-structured-session-publication'
|
||||
import {
|
||||
cancelClaudeAcquisitionAttempt,
|
||||
mintClaudeAcquisitionGeneration,
|
||||
type ClaudeAcquisitionRegistry,
|
||||
type ClaudeSession,
|
||||
type ClaudeSessionExit,
|
||||
type ClaudeStructuredSessionAdapterDeps,
|
||||
type ClaudeAcquireCallbacks
|
||||
} from './claude-structured-session-state'
|
||||
import {
|
||||
closeClaudePublishedSessionForDeps,
|
||||
claudeAcquisitionCleanupError
|
||||
} from './claude-structured-session-close'
|
||||
import { readClaudeTranscriptEntryUuid } from './claude-tui-exit'
|
||||
|
||||
export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000
|
||||
|
||||
export async function acquireClaudeSession({
|
||||
input,
|
||||
deps,
|
||||
sessions,
|
||||
acquisitions,
|
||||
exits,
|
||||
callbacks
|
||||
}: {
|
||||
input: StructuredAgentSessionAcquireInput
|
||||
deps: ClaudeStructuredSessionAdapterDeps
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
exits: Map<string, ClaudeSessionExit>
|
||||
callbacks: ClaudeAcquireCallbacks
|
||||
}): Promise<AgentSessionAcquisition> {
|
||||
// A managed-account switch is mid-swap of the pinned credential home; refuse here,
|
||||
// before this acquisition cancels the previous attempt and closes the live session.
|
||||
if (isClaudeAuthSwitchInProgress()) {
|
||||
throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE))
|
||||
}
|
||||
const sessionId = input.identity.sessionId
|
||||
const prompts = new ClaudePromptRegistry()
|
||||
const translator = createClaudeSessionJournalTranslator(
|
||||
input.events,
|
||||
prompts,
|
||||
String(input.fence)
|
||||
)
|
||||
const { previous, attempt } = acquisitions.start(sessionId, prompts)
|
||||
let liveSession: ClaudeSession | null = null
|
||||
let observedLeafUuid: string | null = null,
|
||||
expectedProviderSessionId: string | null = null
|
||||
// Frames are admitted only after launch resolution proves the provider session
|
||||
// this acquisition owns. Keep the check ahead of every stateful consumer.
|
||||
const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS
|
||||
const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs)
|
||||
|
||||
const onMessage = (message: Record<string, unknown>): void => {
|
||||
const init = readClaudeInit(message)
|
||||
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
|
||||
// An init proof for another (or unnamed) provider must fail acquisition
|
||||
// promptly, while ordinary foreign frames stay quarantined silently.
|
||||
if (init || (message.type === 'system' && message.subtype === 'init')) {
|
||||
initDeadline.reject(new Error('claude provider session expected'))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (init) {
|
||||
initDeadline.resolve(init)
|
||||
// Every turn opens with an init frame naming the model the CLI is actually
|
||||
// running; set_model answers success for a model it never resolves, so this
|
||||
// report is the session's only adoption evidence.
|
||||
if (liveSession && init.model) {
|
||||
liveSession.reportedOptions.model = init.model
|
||||
liveSession.reportedModelMutation = liveSession.optionMutationSequence
|
||||
}
|
||||
}
|
||||
observedLeafUuid = readClaudeTranscriptEntryUuid(message) ?? observedLeafUuid
|
||||
if (liveSession) {
|
||||
liveSession.leafUuid = observedLeafUuid
|
||||
}
|
||||
const startsTurn = liveSession ? resolveClaudeReplayWaiter(liveSession, message) : false
|
||||
callbacks.deliver(attempt, sessionId, () =>
|
||||
callbacks.emit(liveSession, input.events, {
|
||||
type: 'message',
|
||||
sessionId,
|
||||
message,
|
||||
...(startsTurn ? { startsTurn: true } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({
|
||||
sessionId,
|
||||
prompts,
|
||||
emit: (event) =>
|
||||
callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event))
|
||||
})
|
||||
|
||||
try {
|
||||
if (previous && !(await cancelClaudeAcquisitionAttempt(previous))) {
|
||||
acquisitions.restoreIfCurrent(sessionId, attempt, previous)
|
||||
throw new AgentSessionAcquisitionExitUnprovenError(
|
||||
new Error(`claude acquisition for session ${sessionId} could not be stopped`)
|
||||
)
|
||||
}
|
||||
acquisitions.assertCurrent(sessionId, attempt)
|
||||
let resumeSession = sessions.get(sessionId)
|
||||
if (!(await closeClaudePublishedSessionForDeps(sessions, sessionId, deps))) {
|
||||
throw new AgentSessionAcquisitionExitUnprovenError(
|
||||
new Error(`claude session ${sessionId} could not be stopped`)
|
||||
)
|
||||
}
|
||||
// A first-hand exit that has not yet proved its full tree still owns a cleanup
|
||||
// obligation; never let a new acquisition hide that evidence by omission.
|
||||
const retainedExit = exits.get(sessionId)
|
||||
if (retainedExit) {
|
||||
const firstProof = retainedExit.closePromise ? await retainedExit.closePromise : false
|
||||
const proven = firstProof || (await retainedExit.connection.close().catch(() => false))
|
||||
if (!proven) {
|
||||
throw claudeAcquisitionCleanupError(retainedExit.connection, retainedExit.error)
|
||||
}
|
||||
// The old child is superseded by this acquisition. Settle its lifecycle
|
||||
// before discarding the retained proof so its cursor and callbacks are
|
||||
// cleaned up exactly once.
|
||||
await callbacks.settleExit(sessionId, retainedExit)
|
||||
resumeSession ??= retainedExit.session
|
||||
}
|
||||
acquisitions.assertCurrent(sessionId, attempt)
|
||||
// Both close paths persist their final leaf, so launch validates that durable head.
|
||||
const launchIdentity = resumeSession
|
||||
? {
|
||||
...input.identity,
|
||||
providerHandle: {
|
||||
kind: 'claude' as const,
|
||||
sessionId: resumeSession.providerSessionId,
|
||||
leafUuid: resumeSession.leafUuid
|
||||
}
|
||||
}
|
||||
: input.identity
|
||||
const launch = await deps
|
||||
.resolveLaunch({ identity: launchIdentity })
|
||||
.catch((error: unknown) => {
|
||||
throw error instanceof AgentSessionPreSpawnError
|
||||
? error
|
||||
: new AgentSessionPreSpawnError(error)
|
||||
})
|
||||
expectedProviderSessionId = launch.providerSessionId
|
||||
observedLeafUuid = launch.resumeLeafUuid
|
||||
acquisitions.assertCurrent(sessionId, attempt)
|
||||
const open = deps.openConnection ?? openClaudeStreamJsonConnection
|
||||
const connection = await open(
|
||||
{
|
||||
pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable,
|
||||
options: launch.options,
|
||||
cwd: launch.cwd,
|
||||
env: {
|
||||
...launch.env,
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: input.spawnToken,
|
||||
// Compared against what the child would otherwise inherit, so the record's
|
||||
// account home still wins over a diverging overlay without a needless pin.
|
||||
// (`process` is shadowed by a local later in this function, so it is not named here.)
|
||||
...claudeConfigDirEnvPatch(launch.claudeConfigDir, launch.env ? { env: launch.env } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
onMessage,
|
||||
canUseTool,
|
||||
onUserDialog,
|
||||
onFault: (error) => {
|
||||
if (!attempt.published) {
|
||||
initDeadline.reject(error)
|
||||
}
|
||||
},
|
||||
onExit: (error) => {
|
||||
if (!attempt.published) {
|
||||
initDeadline.reject(error)
|
||||
}
|
||||
callbacks.handleExit(sessionId, attempt, error)
|
||||
}
|
||||
}
|
||||
)
|
||||
attempt.connection = connection
|
||||
acquisitions.assertCurrent(sessionId, attempt)
|
||||
initDeadline.start()
|
||||
const [initialization, init] = await Promise.all([
|
||||
requestClaudeInitialization(connection, sessionId, initTimeoutMs),
|
||||
initDeadline.promise
|
||||
])
|
||||
const models = readClaudeModels(initialization)
|
||||
callbacks.deliver(attempt, sessionId, () =>
|
||||
callbacks.emit(liveSession, input.events, { type: 'options', sessionId, models })
|
||||
)
|
||||
initDeadline.clear()
|
||||
acquisitions.assertCurrent(sessionId, attempt)
|
||||
if (init.providerSessionId !== launch.providerSessionId) {
|
||||
throw new Error(
|
||||
`claude proved session ${init.providerSessionId}, expected ${launch.providerSessionId}`
|
||||
)
|
||||
}
|
||||
const settings = await connection
|
||||
.getSettings({ timeoutMs: deps.requestTimeoutMs })
|
||||
.catch(() => null)
|
||||
callbacks.deliver(attempt, sessionId, () =>
|
||||
callbacks.emit(liveSession, input.events, {
|
||||
type: 'auth-diagnostic',
|
||||
sessionId,
|
||||
diagnostic: claudeAuthDiagnostic(init, settings)
|
||||
})
|
||||
)
|
||||
const process = await claudeProcessIdentity(
|
||||
{ ...input, pid: connection.pid },
|
||||
deps.readProcessStartTime
|
||||
)
|
||||
acquisitions.assertCurrent(sessionId, attempt)
|
||||
if (connection.closed) {
|
||||
throw new Error(`claude stream-json for session ${sessionId} exited while being acquired`)
|
||||
}
|
||||
const publication = createClaudeSessionPublication({
|
||||
connection,
|
||||
init,
|
||||
claudeConfigDir: launch.claudeConfigDir,
|
||||
leafUuid: observedLeafUuid,
|
||||
fence: input.fence,
|
||||
effort: readClaudeSettingsEffort(settings),
|
||||
resumed: launch.resumed,
|
||||
prompts,
|
||||
translator,
|
||||
events: input.events,
|
||||
process,
|
||||
acquisitionGeneration: mintClaudeAcquisitionGeneration(deps),
|
||||
options: restoredClaudeStructuredSessionOptions(input.options),
|
||||
capabilities: readClaudeCapabilities(init, initialization),
|
||||
...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}),
|
||||
observedAt: deps.now?.() ?? Date.now()
|
||||
})
|
||||
const acquired: AgentSessionAcquisition = publication.acquisition
|
||||
liveSession = publication.session
|
||||
await restoreClaudeStructuredSessionOptions(liveSession, deps.requestTimeoutMs)
|
||||
acquisitions.assertCurrent(sessionId, attempt)
|
||||
acquisitions.deleteIfCurrent(sessionId, attempt)
|
||||
sessions.set(sessionId, liveSession)
|
||||
attempt.published = true
|
||||
for (const event of attempt.buffered.splice(0)) {
|
||||
event()
|
||||
}
|
||||
return acquired
|
||||
} catch (error) {
|
||||
initDeadline.clear()
|
||||
let acquisitionError = error
|
||||
if (sessions.get(sessionId)?.connection !== attempt.connection) {
|
||||
translator?.dispose()
|
||||
// Settle any callback that fired before the failure so no SDK promise dangles.
|
||||
for (const prompt of prompts.clear()) {
|
||||
prompt.settle(null)
|
||||
}
|
||||
const closed = (await attempt.connection?.close()) ?? true
|
||||
if (attempt.connection?.exitVerdict.root === 'processless') {
|
||||
acquisitionError = new AgentSessionPreSpawnError(error)
|
||||
} else if (!closed) {
|
||||
acquisitionError = claudeAcquisitionCleanupError(attempt.connection, error)
|
||||
}
|
||||
}
|
||||
acquisitions.deleteIfCurrent(sessionId, attempt)
|
||||
throw acquisitionError
|
||||
} finally {
|
||||
attempt.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AgentSessionAcquisitionExitUnprovenError,
|
||||
AgentSessionAcquisitionRefusal,
|
||||
AgentSessionAcquisitionRootExitObservedError
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
|
||||
import { ClaudeControlRequestError } from './claude-stream-json-connection'
|
||||
import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity'
|
||||
import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies'
|
||||
import {
|
||||
CLAUDE_STRUCTURED_INIT_TIMEOUT_MS,
|
||||
type ClaudeStructuredSessionAdapter,
|
||||
type ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-adapter'
|
||||
import {
|
||||
acquired,
|
||||
adapterFor,
|
||||
fakeClaude,
|
||||
identityFor,
|
||||
invokeCanUseTool,
|
||||
PROVIDER_SESSION_ID,
|
||||
tick,
|
||||
USER_MESSAGE,
|
||||
type FakeConnection
|
||||
} from './claude-structured-session-test-support'
|
||||
|
||||
describe('ClaudeStructuredSessionAdapter.acquire', () => {
|
||||
it('finishes its startup deadline before the paired mobile request deadline', () => {
|
||||
expect(CLAUDE_STRUCTURED_INIT_TIMEOUT_MS).toBeLessThan(30_000)
|
||||
})
|
||||
|
||||
it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = adapterFor(claude, {}, events)
|
||||
|
||||
const acquisition = await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9'
|
||||
})
|
||||
|
||||
expect(claude.connections[0].launch).toMatchObject({
|
||||
cwd: '/work/repo',
|
||||
env: {
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9',
|
||||
CLAUDE_CONFIG_DIR: '/accounts/claude'
|
||||
}
|
||||
})
|
||||
// supportedDialogKinds is now a query() launch option, not an initialize request param.
|
||||
expect(claude.connections[0].calls.slice(0, 2)).toEqual([
|
||||
{ subtype: 'initialize' },
|
||||
{ subtype: 'get_settings' }
|
||||
])
|
||||
expect(acquisition.process).toEqual({
|
||||
hostId: 'host-1',
|
||||
pid: 4321,
|
||||
processStartTimeMs: 1_700_000_000_000,
|
||||
spawnToken: 'spawn-9'
|
||||
})
|
||||
expect(acquisition.link).toEqual({
|
||||
linkId: `claude-7-${PROVIDER_SESSION_ID}-empty`,
|
||||
handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null },
|
||||
origin: 'created',
|
||||
mintedAtFence: 7,
|
||||
observedAt: 1_700_000_000_500
|
||||
})
|
||||
expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'init' } })
|
||||
})
|
||||
|
||||
it('restores persisted model and effort before publishing a reacquired session', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = adapterFor(claude, { resumed: true })
|
||||
|
||||
await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
options: { model: 'opus', effort: 'high' }
|
||||
})
|
||||
|
||||
expect(claude.connections[0].calls.slice(-4)).toEqual([
|
||||
{ subtype: 'set_model', params: { model: 'opus' } },
|
||||
// The restored model's advertised levels gate the replay, so a stale effort
|
||||
// is dropped rather than re-applied to a model with no effort control.
|
||||
{ subtype: 'list_models' },
|
||||
{ subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } },
|
||||
// The effort is only recorded once the child reports having adopted it.
|
||||
{ subtype: 'get_settings' }
|
||||
])
|
||||
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
|
||||
current: { model: 'opus', effort: 'high' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['model', 'set_model', { model: 'retired-model' }],
|
||||
['effort', 'apply_flag_settings', { effort: 'retired-effort' }],
|
||||
['permissionMode', 'set_permission_mode', { permissionMode: 'retired-mode' }]
|
||||
] as const)(
|
||||
'self-heals a persisted %s rejected during restore',
|
||||
async (key, subtype, options) => {
|
||||
const claude = fakeClaude({
|
||||
routes: {
|
||||
[subtype]: () => {
|
||||
throw new ClaudeControlRequestError(subtype, 'value is no longer available')
|
||||
}
|
||||
}
|
||||
})
|
||||
const adapter = adapterFor(claude)
|
||||
|
||||
await expect(
|
||||
adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
options
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key])
|
||||
}
|
||||
)
|
||||
|
||||
it('does not treat a transport timeout while restoring an option as recoverable', async () => {
|
||||
const claude = fakeClaude({
|
||||
routes: {
|
||||
set_model: () => {
|
||||
throw new Error('claude set_model request timed out')
|
||||
}
|
||||
}
|
||||
})
|
||||
const adapter = adapterFor(claude)
|
||||
const input = {
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
options: { model: 'temporarily-unavailable' }
|
||||
}
|
||||
|
||||
await expect(adapter.acquire(input)).rejects.toThrow('claude set_model request timed out')
|
||||
expect(claude.connections[0]?.closeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('recovers a cancellable lifecycle when a timed-out replay arrives late', async () => {
|
||||
const claude = fakeClaude({ replayUuid: null })
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = await acquired(claude, {}, events)
|
||||
|
||||
await expect(
|
||||
adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-1',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
).resolves.toMatchObject({ state: 'unknown' })
|
||||
const sent = claude.connections[0]!.sent[0]!
|
||||
claude.connections[0]!.handlers.onMessage?.({
|
||||
...sent,
|
||||
uuid: 'late-turn-1'
|
||||
})
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'message',
|
||||
startsTurn: true,
|
||||
message: expect.objectContaining({ uuid: 'late-turn-1' })
|
||||
})
|
||||
)
|
||||
await expect(
|
||||
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'late-turn-1', fence: 7 })
|
||||
).resolves.toEqual({ cancelled: true })
|
||||
})
|
||||
|
||||
it('quarantines SDK frames without the acquired session identity', async () => {
|
||||
const claude = fakeClaude({ replayUuid: null })
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = await acquired(claude, {}, events)
|
||||
const connection = claude.connections[0]!
|
||||
|
||||
connection.handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
uuid: 'foreign-leaf',
|
||||
session_id: 'foreign-provider-session',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'do not admit' }] }
|
||||
})
|
||||
connection.handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
uuid: 'missing-session-leaf',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'do not admit' }] }
|
||||
})
|
||||
|
||||
const dispatch = adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-1',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(connection.sent).toHaveLength(1)
|
||||
connection.handlers.onMessage?.({
|
||||
...connection.sent[0],
|
||||
uuid: 'foreign-replay',
|
||||
session_id: 'foreign-provider-session'
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(events.filter((event) => event.type === 'message')).toHaveLength(1)
|
||||
|
||||
connection.handlers.onMessage?.({
|
||||
...connection.sent[0],
|
||||
session_id: PROVIDER_SESSION_ID
|
||||
})
|
||||
await expect(dispatch).resolves.toMatchObject({
|
||||
state: 'accepted',
|
||||
providerIdentity: { uuid: connection.sent[0]!.uuid }
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards configured launch environment while keeping ownership pins authoritative', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = adapterFor(claude, {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
|
||||
CLAUDE_CONFIG_DIR: '/wrong/account',
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: 'wrong-token'
|
||||
}
|
||||
})
|
||||
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
|
||||
expect(claude.connections[0].launch.env).toEqual({
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
|
||||
CLAUDE_CONFIG_DIR: '/accounts/claude',
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves CLAUDE_CONFIG_DIR unset when the account home is the CLI default', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = adapterFor(claude, { claudeConfigDir: join(homedir(), '.claude'), env: {} })
|
||||
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
|
||||
// Pinning the CLI's own default suppresses the macOS Keychain and breaks claude.ai login.
|
||||
expect(claude.connections[0].launch.env).toEqual({ [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9' })
|
||||
})
|
||||
|
||||
it('re-pins the account home when the launch env would send the child elsewhere', async () => {
|
||||
const claude = fakeClaude()
|
||||
const accountHome = join(homedir(), '.claude')
|
||||
const adapter = adapterFor(claude, {
|
||||
claudeConfigDir: accountHome,
|
||||
env: { CLAUDE_CONFIG_DIR: '/other/account' }
|
||||
})
|
||||
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
|
||||
expect(claude.connections[0].launch.env).toEqual({
|
||||
CLAUDE_CONFIG_DIR: accountHome,
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts SessionStart as pre-turn proof without treating its system uuid as a leaf', async () => {
|
||||
const claude = fakeClaude({ initProof: 'session-start', initUuid: 'session-start-uuid' })
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = adapterFor(claude, {}, events)
|
||||
|
||||
const acquisition = await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9'
|
||||
})
|
||||
|
||||
expect(acquisition.link.handle).toEqual({
|
||||
provider: 'claude',
|
||||
sessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: null
|
||||
})
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'message',
|
||||
message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' }
|
||||
})
|
||||
})
|
||||
|
||||
it('records only non-secret effective auth-lane diagnostics', async () => {
|
||||
const claude = fakeClaude({
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
|
||||
ANTHROPIC_AUTH_TOKEN: 'secret'
|
||||
}
|
||||
}
|
||||
})
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
await acquired(claude, {}, events)
|
||||
|
||||
const diagnostic = events.find((event) => event.type === 'auth-diagnostic')
|
||||
expect(diagnostic).toEqual({
|
||||
type: 'auth-diagnostic',
|
||||
sessionId: 'session-1',
|
||||
diagnostic: {
|
||||
apiKeySourceConfigured: false,
|
||||
baseUrlConfigured: true,
|
||||
authTokenConfigured: true,
|
||||
apiKeyConfigured: false,
|
||||
settingSources: ['user', 'project', 'local']
|
||||
}
|
||||
})
|
||||
expect(JSON.stringify(diagnostic)).not.toContain('secret')
|
||||
expect(JSON.stringify(diagnostic)).not.toContain('gateway.example.test')
|
||||
})
|
||||
|
||||
it('resumes the same provider id and refuses an init proof for another session', async () => {
|
||||
const resumedClaude = fakeClaude()
|
||||
const resumed = adapterFor(resumedClaude, {
|
||||
resumed: true,
|
||||
resumeLeafUuid: 'leaf-before'
|
||||
})
|
||||
const acquisition = await resumed.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 9,
|
||||
spawnToken: 'spawn-9'
|
||||
})
|
||||
expect(acquisition.link.origin).toBe('resumed')
|
||||
expect(acquisition.link.handle).toEqual({
|
||||
provider: 'claude',
|
||||
sessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: 'leaf-before'
|
||||
})
|
||||
|
||||
const wrongClaude = fakeClaude({ initSessionId: 'different-session' })
|
||||
const wrong = adapterFor(wrongClaude)
|
||||
await expect(
|
||||
wrong.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
).rejects.toThrow(/expected/)
|
||||
expect(wrongClaude.connections[0].closeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('surfaces a CLI startup failure instead of waiting for the init deadline', async () => {
|
||||
const claude = fakeClaude({ exitBeforeInit: 'Claude login required' })
|
||||
const adapter = adapterFor(claude)
|
||||
|
||||
await expect(
|
||||
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
).rejects.toThrow('Claude login required')
|
||||
expect(claude.connections[0].closeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('closes a silent unauthenticated startup with actionable account guidance', async () => {
|
||||
const claude = fakeClaude({ initProof: 'none' })
|
||||
const adapter = adapterFor(claude, {}, [], [], 20)
|
||||
|
||||
const error = await adapter
|
||||
.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
.catch((cause: unknown) => cause)
|
||||
|
||||
expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal)
|
||||
expect(error).toMatchObject({
|
||||
message: expect.stringMatching(/selected Claude account is signed in.*CLAUDE_CONFIG_DIR/s)
|
||||
})
|
||||
expect(claude.connections[0].calls[0]).toEqual({ subtype: 'initialize' })
|
||||
expect(claude.connections[0].closeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses an unauthenticated initialize response even when SessionStart runs', async () => {
|
||||
const claude = fakeClaude({
|
||||
initProof: 'session-start',
|
||||
initAccount: { apiProvider: 'firstParty', tokenSource: 'none' }
|
||||
})
|
||||
const adapter = adapterFor(claude)
|
||||
|
||||
await expect(
|
||||
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s)
|
||||
expect(claude.connections[0].closeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClaudeStructuredSessionAdapter turns and controls', () => {
|
||||
it('accepts a dispatch only after Claude replays its provider uuid', async () => {
|
||||
const claude = fakeClaude({ replayUuid: 'user-provider-uuid' })
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
const result = await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-1',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
state: 'accepted',
|
||||
providerIdentity: {
|
||||
provider: 'claude',
|
||||
sessionId: PROVIDER_SESSION_ID,
|
||||
uuid: 'user-provider-uuid'
|
||||
}
|
||||
})
|
||||
expect(claude.connections[0].sent[0]).toMatchObject({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'ship it' }] },
|
||||
session_id: PROVIDER_SESSION_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves delivery unconfirmed when no replay uuid arrives', async () => {
|
||||
const adapter = await acquired(fakeClaude({ replayUuid: null }))
|
||||
await expect(
|
||||
adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-1',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
).resolves.toMatchObject({ state: 'unknown' })
|
||||
})
|
||||
|
||||
it('requires an acknowledged interrupt and supports controlled options', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = await acquired(claude)
|
||||
await expect(
|
||||
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
|
||||
).resolves.toEqual({ cancelled: true })
|
||||
await expect(
|
||||
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 })
|
||||
).resolves.toEqual({ model: 'sonnet' })
|
||||
expect(claude.connections[0].calls.slice(-2)).toEqual([
|
||||
{ subtype: 'interrupt', params: {} },
|
||||
{ subtype: 'set_model', params: { model: 'sonnet' } }
|
||||
])
|
||||
|
||||
claude.routes.interrupt = () => {
|
||||
throw new ClaudeControlRequestError('interrupt', 'not running')
|
||||
}
|
||||
await expect(
|
||||
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-2', fence: 7 })
|
||||
).resolves.toEqual({ cancelled: false })
|
||||
|
||||
claude.routes.interrupt = () => {
|
||||
throw new Error('claude interrupt request timed out')
|
||||
}
|
||||
await expect(
|
||||
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-3', fence: 7 })
|
||||
).rejects.toThrow('timed out')
|
||||
})
|
||||
|
||||
it('does not let a delayed cancellation for an earlier turn interrupt the later turn', async () => {
|
||||
const claude = fakeClaude({ replayUuids: ['turn-T', 'turn-U'] })
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-T',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-U',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 })
|
||||
).resolves.toEqual({ cancelled: false })
|
||||
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
|
||||
0
|
||||
)
|
||||
|
||||
await expect(
|
||||
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 6 })
|
||||
).resolves.toEqual({ cancelled: false })
|
||||
|
||||
await expect(
|
||||
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 7 })
|
||||
).resolves.toEqual({ cancelled: true })
|
||||
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('does not cancel an acknowledged turn after a later dispatch returns unknown', async () => {
|
||||
const claude = fakeClaude({ replayUuids: ['turn-T', null] })
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await expect(
|
||||
adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-T',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'accepted',
|
||||
providerIdentity: { uuid: 'turn-T' }
|
||||
})
|
||||
await expect(
|
||||
adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-U',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
).resolves.toMatchObject({ state: 'unknown' })
|
||||
expect(claude.connections[0].sent).toHaveLength(2)
|
||||
|
||||
await expect(
|
||||
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 })
|
||||
).resolves.toEqual({ cancelled: false })
|
||||
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies provider-declined options without treating timeouts as settled', async () => {
|
||||
const claude = fakeClaude({
|
||||
routes: {
|
||||
set_model: () => {
|
||||
throw new ClaudeControlRequestError('set_model', 'model unavailable')
|
||||
}
|
||||
}
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await expect(
|
||||
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'fable', fence: 7 })
|
||||
).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' })
|
||||
claude.routes.set_model = () => {
|
||||
throw new Error('claude set_model request timed out')
|
||||
}
|
||||
await expect(
|
||||
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 })
|
||||
).rejects.toThrow('timed out')
|
||||
})
|
||||
|
||||
it('hydrates live model choices and maps the resolved current model to its CLI id', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'claude-sonnet-5',
|
||||
routes: {
|
||||
list_models: () => [
|
||||
{ value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' },
|
||||
{
|
||||
value: 'opus',
|
||||
resolvedModel: 'claude-opus-5',
|
||||
displayName: 'Opus',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'high']
|
||||
},
|
||||
{
|
||||
value: 'sonnet',
|
||||
resolvedModel: 'claude-sonnet-5',
|
||||
displayName: 'Sonnet'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({
|
||||
models: [
|
||||
{
|
||||
id: 'opus',
|
||||
label: 'Opus',
|
||||
isDefault: true,
|
||||
efforts: [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'high', label: 'High' }
|
||||
]
|
||||
},
|
||||
{ id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: [] }
|
||||
],
|
||||
current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the shared Claude seed when live model discovery is unavailable', async () => {
|
||||
const claude = fakeClaude({
|
||||
initModel: 'custom-model',
|
||||
routes: {
|
||||
list_models: () => {
|
||||
throw new Error('unsupported')
|
||||
}
|
||||
}
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
|
||||
expect(result.models.map((model) => model.id)).toEqual([
|
||||
'fable',
|
||||
'opus',
|
||||
'sonnet',
|
||||
'haiku',
|
||||
'custom-model'
|
||||
])
|
||||
expect(result.current).toEqual({
|
||||
model: 'custom-model',
|
||||
effort: 'high',
|
||||
confirmed: ['model', 'effort']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => {
|
||||
/** A start that fails after the child self-exited, with its close verdict scripted. */
|
||||
function failedStart(
|
||||
unprovenCloseVerdict: ClaudeStreamJsonConnection['exitVerdict']
|
||||
): Promise<unknown> {
|
||||
const claude = fakeClaude({
|
||||
exitBeforeInit: 'claude stream-json exited (code 1): not logged in',
|
||||
unprovenCloseVerdict
|
||||
})
|
||||
return adapterFor(claude)
|
||||
.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
.catch((error: unknown) => error)
|
||||
}
|
||||
|
||||
it('releases on a first-hand root exit while still carrying the CLI diagnostic', async () => {
|
||||
// The root's pid and start time are the lease's identity, and they are
|
||||
// provably dead: latching the session would strand a signed-out user.
|
||||
const error = await failedStart({ root: 'exited', tree: 'unverifiable' })
|
||||
|
||||
expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
|
||||
expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in')
|
||||
})
|
||||
|
||||
it('never releases while a descendant was observed alive', async () => {
|
||||
const error = await failedStart({ root: 'exited', tree: 'live' })
|
||||
|
||||
expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError)
|
||||
expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
|
||||
})
|
||||
|
||||
it('never releases for a root Orca never saw leave', async () => {
|
||||
const error = await failedStart({ root: 'live', tree: 'unverifiable' })
|
||||
|
||||
expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError)
|
||||
expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
|
||||
})
|
||||
|
||||
/** A published session whose CLI then exits first-hand, with the verdict its ladder holds. */
|
||||
async function exitedAfterPublish(
|
||||
exitVerdict: ClaudeStreamJsonConnection['exitVerdict']
|
||||
): Promise<{ adapter: ClaudeStructuredSessionAdapter; connection: FakeConnection }> {
|
||||
const claude = fakeClaude({ unprovenCloseVerdict: exitVerdict })
|
||||
const adapter = await acquired(claude)
|
||||
const connection = claude.connections[0]
|
||||
connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed'))
|
||||
return { adapter, connection }
|
||||
}
|
||||
|
||||
it('classifies cleanup after a first-hand exit removed the session as a root exit, never as proven', async () => {
|
||||
// The host may still be committing or proving the lease when the child dies;
|
||||
// its cleanup must find the exit the ladder observed, not an absence.
|
||||
const { adapter, connection } = await exitedAfterPublish({
|
||||
root: 'exited',
|
||||
tree: 'unverifiable'
|
||||
})
|
||||
const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e)
|
||||
|
||||
expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
|
||||
expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed')
|
||||
expect(connection.closeCount).toBe(2)
|
||||
})
|
||||
|
||||
it('never releases after an exit that left a descendant observed alive', async () => {
|
||||
const { adapter } = await exitedAfterPublish({ root: 'exited', tree: 'live' })
|
||||
const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e)
|
||||
|
||||
expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError)
|
||||
expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
|
||||
})
|
||||
|
||||
it('forgets a retained exit once the session is acquired again', async () => {
|
||||
const options: Parameters<typeof fakeClaude>[0] = {}
|
||||
const claude = fakeClaude(options)
|
||||
const adapter = await acquired(claude)
|
||||
const first = claude.connections[0]
|
||||
first.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed'))
|
||||
first.exitVerdict = { root: 'exited', tree: 'unverifiable' }
|
||||
first.close = async () => false
|
||||
options.exitBeforeInit = 'claude stream-json exited (code 1): not logged in'
|
||||
|
||||
await expect(
|
||||
adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' })
|
||||
).rejects.toThrow('not logged in')
|
||||
// The second start's own proven close is the answer; the first exit is stale.
|
||||
await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true)
|
||||
expect(first.closeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('reports unproven published-session cleanup so callers can retry safely', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = await acquired(claude)
|
||||
const connection = claude.connections[0]
|
||||
connection.close = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true) as unknown as FakeConnection['close']
|
||||
|
||||
await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false)
|
||||
expect(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toMatchObject({
|
||||
current: { model: 'claude-sonnet-5' }
|
||||
})
|
||||
await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true)
|
||||
expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toThrow(
|
||||
'no live claude stream-json session'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not report a second release as successful while retained exit evidence is unproven', async () => {
|
||||
const claude = fakeClaude({ unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } })
|
||||
const adapter = await acquired(claude)
|
||||
const connection = claude.connections[0]
|
||||
connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed'))
|
||||
connection.close = vi.fn().mockResolvedValue(false) as unknown as FakeConnection['close']
|
||||
|
||||
await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBeInstanceOf(
|
||||
AgentSessionAcquisitionRootExitObservedError
|
||||
)
|
||||
await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBeInstanceOf(
|
||||
AgentSessionAcquisitionRootExitObservedError
|
||||
)
|
||||
expect(connection.close).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps shutdown pending until a retained unexpected-exit proof settles', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = await acquired(claude)
|
||||
const connection = claude.connections[0]
|
||||
const proof = Promise.withResolvers<boolean>()
|
||||
connection.close = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockImplementationOnce(() => proof.promise)
|
||||
.mockResolvedValueOnce(true) as unknown as FakeConnection['close']
|
||||
|
||||
connection.handlers.onExit?.(new Error('crashed'))
|
||||
await tick()
|
||||
let settled = false
|
||||
const closing = adapter.closeAll().then(() => {
|
||||
settled = true
|
||||
})
|
||||
await tick()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
proof.resolve(false)
|
||||
await expect(closing).resolves.toBeUndefined()
|
||||
expect(connection.close).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not claim shutdown success for a retained false exit proof', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = await acquired(claude, {}, events)
|
||||
const connection = claude.connections[0]
|
||||
connection.close = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValue(false) as unknown as FakeConnection['close']
|
||||
|
||||
connection.handlers.onExit?.(new Error('crashed'))
|
||||
await tick()
|
||||
|
||||
await expect(adapter.closeAll()).rejects.toThrow(
|
||||
'claude structured session shutdown could not prove every child stopped'
|
||||
)
|
||||
expect(events.filter((event) => event.type === 'ended')).toEqual([])
|
||||
expect(connection.close).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClaudeStructuredSessionAdapter prompts', () => {
|
||||
it('turns can_use_tool into an addressable durable approval that settles the SDK callback', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = await acquired(claude, {}, events)
|
||||
const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1', {
|
||||
input: { command: 'git status' },
|
||||
suggestions: [{ type: 'addRules' }]
|
||||
})
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'prompt',
|
||||
prompt: { kind: 'approval', toolName: 'Bash', promptKey: 'permission-1' }
|
||||
})
|
||||
|
||||
adapter.bindPromptItemId('session-1', 'journal-approval', 'permission-1')
|
||||
await adapter.answerPrompt({
|
||||
sessionId: 'session-1',
|
||||
itemId: 'journal-approval',
|
||||
kind: 'approval',
|
||||
optionId: 'allowForSession',
|
||||
fence: 7
|
||||
})
|
||||
// The answer resolves the SDK's own callback promise; the SDK writes the wire response.
|
||||
await expect(answered.promise).resolves.toEqual({
|
||||
behavior: 'allow',
|
||||
updatedInput: { command: 'git status' },
|
||||
updatedPermissions: [{ type: 'addRules' }],
|
||||
toolUseID: 'tool-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('collects every AskUserQuestion card before settling the one callback', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = await acquired(claude)
|
||||
const answered = invokeCanUseTool(
|
||||
claude.connections[0],
|
||||
'AskUserQuestion',
|
||||
'question-1',
|
||||
'tool-question',
|
||||
{
|
||||
input: {
|
||||
questions: [
|
||||
{ question: 'Library?', options: [{ label: 'Luxon' }] },
|
||||
{ question: 'Ship now?', options: [{ label: 'Yes' }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
adapter.bindPromptItemId('session-1', 'journal-q1', 'question-1', 'Library?')
|
||||
adapter.bindPromptItemId('session-1', 'journal-q2', 'question-1', 'Ship now?')
|
||||
|
||||
await adapter.answerPrompt({
|
||||
sessionId: 'session-1',
|
||||
itemId: 'journal-q1',
|
||||
kind: 'question',
|
||||
optionId: encodeClaudeQuestionOptionId('Library?', 'Luxon'),
|
||||
fence: 7
|
||||
})
|
||||
await tick()
|
||||
expect(answered.settled()).toBe(false)
|
||||
await adapter.answerPrompt({
|
||||
sessionId: 'session-1',
|
||||
itemId: 'journal-q2',
|
||||
kind: 'question',
|
||||
optionId: encodeClaudeQuestionOptionId('Ship now?', 'Yes'),
|
||||
fence: 7
|
||||
})
|
||||
await expect(answered.promise).resolves.toMatchObject({
|
||||
behavior: 'allow',
|
||||
updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': 'Yes' } },
|
||||
toolUseID: 'tool-question'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = await acquired(claude, {}, events)
|
||||
const controller = new AbortController()
|
||||
const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-9', 'tool-9', {
|
||||
input: { command: 'rm -rf /' },
|
||||
signal: controller.signal
|
||||
})
|
||||
adapter.bindPromptItemId('session-1', 'journal-9', 'permission-9')
|
||||
|
||||
controller.abort()
|
||||
// A cancelled request is forgotten and settled with null — never an authorization.
|
||||
await expect(answered.promise).resolves.toBeNull()
|
||||
expect(events.at(-1)).toMatchObject({ type: 'prompt-cancelled', promptKey: 'permission-9' })
|
||||
// A late answer after the abort must not authorize the wrong tool.
|
||||
await expect(
|
||||
adapter.answerPrompt({
|
||||
sessionId: 'session-1',
|
||||
itemId: 'journal-9',
|
||||
kind: 'approval',
|
||||
optionId: 'allow',
|
||||
fence: 7
|
||||
})
|
||||
).rejects.toThrow(/no longer waiting/)
|
||||
})
|
||||
|
||||
it('settles an in-flight permission callback when the session closes, leaving no dangling promise', async () => {
|
||||
const claude = fakeClaude()
|
||||
const adapter = await acquired(claude)
|
||||
const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-close', 'tool-c', {
|
||||
input: { command: 'ls' }
|
||||
})
|
||||
await tick()
|
||||
expect(answered.settled()).toBe(false)
|
||||
|
||||
await adapter.closeSession('session-1')
|
||||
|
||||
await expect(answered.promise).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
AgentSessionAcquisition,
|
||||
StructuredAgentSessionAcquireInput,
|
||||
StructuredAgentSessionAdapter
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions'
|
||||
import { dispatchClaudeTurn } from './claude-structured-dispatch'
|
||||
import { releaseClaudeAcquisition } from './claude-structured-acquisition-release'
|
||||
import { acquireClaudeSession } from './claude-structured-session-acquisition'
|
||||
export { CLAUDE_STRUCTURED_INIT_TIMEOUT_MS } from './claude-structured-session-acquisition'
|
||||
import { supportsClaudeStructuredLocation } from './claude-structured-location-support'
|
||||
import { setClaudeStructuredOption } from './claude-structured-options'
|
||||
import { readClaudeStructuredSessionOptions } from './claude-structured-session-options'
|
||||
import {
|
||||
ClaudeAcquisitionRegistry,
|
||||
type ClaudeAcquisitionAttempt,
|
||||
type ClaudeSession,
|
||||
type ClaudeSessionExit,
|
||||
type ClaudeStructuredSessionAdapterDeps,
|
||||
type ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-state'
|
||||
import {
|
||||
closeAllClaudeSessions,
|
||||
closeClaudeSession,
|
||||
settleClaudeExitedSession
|
||||
} from './claude-structured-session-close'
|
||||
import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof'
|
||||
|
||||
export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
|
||||
export type {
|
||||
ClaudeAuthDiagnostic,
|
||||
ClaudeStructuredSessionAdapterDeps,
|
||||
ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-state'
|
||||
|
||||
const DISPATCH_ACK_TIMEOUT_MS = 10_000
|
||||
|
||||
export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter {
|
||||
private readonly sessions = new Map<string, ClaudeSession>()
|
||||
private readonly acquisitions = new ClaudeAcquisitionRegistry()
|
||||
private readonly exits = new Map<string, ClaudeSessionExit>()
|
||||
|
||||
constructor(private readonly deps: ClaudeStructuredSessionAdapterDeps) {}
|
||||
|
||||
supportsLocation = supportsClaudeStructuredLocation
|
||||
|
||||
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> =>
|
||||
acquireClaudeSession({
|
||||
input,
|
||||
deps: this.deps,
|
||||
sessions: this.sessions,
|
||||
acquisitions: this.acquisitions,
|
||||
exits: this.exits,
|
||||
callbacks: {
|
||||
deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event),
|
||||
emit: (session, events, event) => this.emit(session, events, event),
|
||||
handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error),
|
||||
settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit)
|
||||
}
|
||||
})
|
||||
|
||||
private deliver(attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void): void {
|
||||
if (!attempt.published) {
|
||||
attempt.buffered.push(event)
|
||||
return
|
||||
}
|
||||
if (this.sessions.get(sessionId)?.connection === attempt.connection) {
|
||||
event()
|
||||
}
|
||||
}
|
||||
|
||||
private handleExit(sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || session.connection !== attempt.connection) {
|
||||
return
|
||||
}
|
||||
this.sessions.delete(sessionId)
|
||||
// Re-enter the provider's close ladder before publishing lifecycle recovery.
|
||||
// An exit callback is root evidence only; the retained tree proof must run
|
||||
// before the host releases and reacquires this exact child.
|
||||
const closePromise = session.connection.close().catch(() => false)
|
||||
const exit: ClaudeSessionExit = {
|
||||
connection: session.connection,
|
||||
session,
|
||||
error,
|
||||
closePromise
|
||||
}
|
||||
this.exits.set(sessionId, exit)
|
||||
void closePromise
|
||||
.then((proven) => (proven ? this.settleUnexpectedExit(sessionId, exit) : undefined))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
/** Lifecycle recovery is published only after the child tree proof is true. */
|
||||
private settleUnexpectedExit(sessionId: string, exit: ClaudeSessionExit): Promise<void> {
|
||||
exit.settlementPromise ??= (async () => {
|
||||
if (this.exits.get(sessionId) !== exit) {
|
||||
settleClaudeExitedSession(exit.session)
|
||||
return
|
||||
}
|
||||
// Persist the transcript-derived cursor before publishing the lifecycle
|
||||
// event that lets the host release and reacquire this exact child.
|
||||
await this.persistSessionHandle(sessionId, exit.session).catch(() => undefined)
|
||||
if (this.exits.get(sessionId) !== exit) {
|
||||
settleClaudeExitedSession(exit.session)
|
||||
return
|
||||
}
|
||||
this.exits.delete(sessionId)
|
||||
const ended: ClaudeStructuredSessionEvent = {
|
||||
type: 'ended',
|
||||
sessionId,
|
||||
reason: exit.error.message,
|
||||
cause: 'unexpected-exit',
|
||||
fence: exit.session.fence,
|
||||
acquisitionGeneration: exit.session.acquisitionGeneration
|
||||
}
|
||||
try {
|
||||
this.emit(exit.session, exit.session.events, ended)
|
||||
} finally {
|
||||
settleClaudeExitedSession(exit.session)
|
||||
}
|
||||
})()
|
||||
return exit.settlementPromise
|
||||
}
|
||||
|
||||
private async persistSessionHandle(sessionId: string, session: ClaudeSession): Promise<void> {
|
||||
try {
|
||||
const transcriptLeaf = this.deps.readTranscriptLeaf
|
||||
? await readClaudeTranscriptLeafWithReproof({
|
||||
readTranscriptLeaf: this.deps.readTranscriptLeaf,
|
||||
providerSessionId: session.providerSessionId,
|
||||
previousLeafUuid: session.leafUuid,
|
||||
claudeConfigDir: session.claudeConfigDir
|
||||
})
|
||||
: null
|
||||
if (transcriptLeaf) {
|
||||
session.leafUuid = transcriptLeaf
|
||||
}
|
||||
} catch {
|
||||
// A stale or unavailable tail must not overwrite the last observed leaf.
|
||||
}
|
||||
await this.deps.persistHandle?.({
|
||||
sessionId,
|
||||
providerSessionId: session.providerSessionId,
|
||||
leafUuid: session.leafUuid,
|
||||
fence: session.fence
|
||||
})
|
||||
}
|
||||
|
||||
private emit(
|
||||
_session: ClaudeSession | null,
|
||||
_events: StructuredAgentSessionEventSink | undefined,
|
||||
event: ClaudeStructuredSessionEvent
|
||||
): void {
|
||||
_session?.translator?.handle(event)
|
||||
this.deps.onEvent?.(event)
|
||||
}
|
||||
|
||||
bindPromptItemId(
|
||||
sessionId: string,
|
||||
journalItemId: string,
|
||||
promptKey: string,
|
||||
questionId?: string
|
||||
): void {
|
||||
this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey, questionId)
|
||||
}
|
||||
|
||||
dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
|
||||
dispatchClaudeTurn(
|
||||
this.session(input.sessionId),
|
||||
input,
|
||||
this.deps.dispatchAckTimeoutMs ?? DISPATCH_ACK_TIMEOUT_MS
|
||||
)
|
||||
|
||||
cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => {
|
||||
const session = this.session(input.sessionId)
|
||||
const acquisitionGeneration = session.acquisitionGeneration
|
||||
return cancelClaudeTurn(session, this.deps.requestTimeoutMs, () => {
|
||||
// Keep every ownership check adjacent to the provider interrupt. The
|
||||
// session map check fences a replaced child; the turn check fences a
|
||||
// delayed cancel after a newer turn was admitted on the same child.
|
||||
return (
|
||||
this.sessions.get(input.sessionId) === session &&
|
||||
session.fence === input.fence &&
|
||||
session.acquisitionGeneration === acquisitionGeneration &&
|
||||
(session.activeTurnId === undefined
|
||||
? session.dispatchSequence === 0
|
||||
: session.activeTurnId === input.turnId &&
|
||||
session.activeTurnSequence === session.dispatchSequence)
|
||||
)
|
||||
})
|
||||
}
|
||||
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
|
||||
answerClaudePrompt(this.session(input.sessionId), input)
|
||||
setOption: StructuredAgentSessionAdapter['setOption'] = (input) =>
|
||||
setClaudeStructuredOption(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
|
||||
readOptions = (input: { sessionId: string; fence: number }) =>
|
||||
readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
|
||||
|
||||
readOptionRestoreFailures = (sessionId: string): readonly string[] => [
|
||||
...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? [])
|
||||
]
|
||||
|
||||
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
|
||||
releaseClaudeAcquisition({
|
||||
sessionId: input.sessionId,
|
||||
sessions: this.sessions,
|
||||
acquisitions: this.acquisitions,
|
||||
exits: this.exits,
|
||||
onExitProven: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit),
|
||||
...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}),
|
||||
...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {})
|
||||
})
|
||||
|
||||
closeSession = (sessionId: string): Promise<boolean> => {
|
||||
if (this.exits.has(sessionId)) {
|
||||
return this.releaseAcquisition({ sessionId })
|
||||
}
|
||||
return closeClaudeSession({
|
||||
sessionId,
|
||||
sessions: this.sessions,
|
||||
acquisitions: this.acquisitions,
|
||||
...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}),
|
||||
...(this.deps.readTranscriptLeaf ? { readTranscriptLeaf: this.deps.readTranscriptLeaf } : {}),
|
||||
...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {})
|
||||
})
|
||||
}
|
||||
|
||||
closeAll = (): Promise<void> =>
|
||||
closeAllClaudeSessions({
|
||||
sessions: this.sessions,
|
||||
acquisitions: this.acquisitions,
|
||||
exits: this.exits,
|
||||
closeSession: this.closeSession,
|
||||
closeExit: (sessionId) => this.releaseAcquisition({ sessionId })
|
||||
})
|
||||
|
||||
private session(sessionId: string): ClaudeSession {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error(`no live claude stream-json session for ${sessionId}`)
|
||||
}
|
||||
return session
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type {
|
||||
ClaudeStructuredSessionAdapterDeps,
|
||||
ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-adapter'
|
||||
import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support'
|
||||
|
||||
describe('Claude published session close lifecycle', () => {
|
||||
it('ends the session even when the durable handle write rejects', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const persistenceError = new Error('store unavailable')
|
||||
const persistHandle = vi
|
||||
.fn<NonNullable<ClaudeStructuredSessionAdapterDeps['persistHandle']>>()
|
||||
.mockRejectedValueOnce(persistenceError)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const adapter = adapterFor(claude, {}, events, [], undefined, undefined, persistHandle)
|
||||
const journalSink: StructuredAgentSessionEventSink = {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
events: journalSink
|
||||
})
|
||||
const session = (
|
||||
adapter as unknown as {
|
||||
sessions: Map<string, { translator: { dispose: () => void } | null }>
|
||||
}
|
||||
).sessions.get('session-1')
|
||||
const disposeTranslator = vi.spyOn(session!.translator!, 'dispose')
|
||||
|
||||
await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError)
|
||||
// The child is provably dead; a failed cursor write may not suppress the end.
|
||||
expect(events.filter((event) => event.type === 'ended')).toHaveLength(1)
|
||||
expect(events.filter((event) => event.type === 'handle')).toHaveLength(0)
|
||||
expect(disposeTranslator).toHaveBeenCalledOnce()
|
||||
|
||||
await expect(adapter.closeSession('session-1')).resolves.toBe(true)
|
||||
expect(persistHandle).toHaveBeenCalledTimes(2)
|
||||
// The retry persists the same cursor without a second lifecycle end.
|
||||
expect(events.filter((event) => event.type === 'handle')).toHaveLength(1)
|
||||
expect(events.filter((event) => event.type === 'ended')).toHaveLength(1)
|
||||
expect(disposeTranslator).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import type {
|
||||
ClaudeAcquisitionRegistry,
|
||||
ClaudeSession,
|
||||
ClaudeSessionExit,
|
||||
ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-state'
|
||||
import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state'
|
||||
import {
|
||||
AgentSessionAcquisitionExitUnprovenError,
|
||||
AgentSessionAcquisitionRootExitObservedError,
|
||||
AgentSessionPreSpawnError
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
|
||||
import { closeProcessRegistry } from '../../shared/child-process/close-process-registry'
|
||||
import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof'
|
||||
|
||||
export function claudeAcquisitionCleanupError(
|
||||
connection: ClaudeStreamJsonConnection | null | undefined,
|
||||
cause: unknown
|
||||
): Error {
|
||||
const verdict = connection?.exitVerdict
|
||||
if (verdict?.root === 'processless') {
|
||||
return new AgentSessionPreSpawnError(cause)
|
||||
}
|
||||
return verdict?.root === 'exited' && verdict.tree === 'unverifiable'
|
||||
? new AgentSessionAcquisitionRootExitObservedError(cause)
|
||||
: new AgentSessionAcquisitionExitUnprovenError(cause)
|
||||
}
|
||||
|
||||
export function settleClaudeDispatchWaiters(session: ClaudeSession): void {
|
||||
for (const waiter of session.dispatchWaiters.splice(0)) {
|
||||
clearTimeout(waiter.timer)
|
||||
waiter.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
export function settleClaudeExitedSession(session: ClaudeSession): void {
|
||||
settleClaudeDispatchWaiters(session)
|
||||
for (const prompt of session.prompts.clear()) {
|
||||
prompt.settle(null)
|
||||
}
|
||||
session.translator?.dispose()
|
||||
}
|
||||
|
||||
type CloseClaudePublishedSessionInput = {
|
||||
sessions: Map<string, ClaudeSession>
|
||||
sessionId: string
|
||||
persistHandle?: (handle: {
|
||||
sessionId: string
|
||||
providerSessionId: string
|
||||
leafUuid: string | null
|
||||
fence: number
|
||||
}) => Promise<void>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
readTranscriptLeaf?: (input: {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
claudeConfigDir: string
|
||||
}) => Promise<string | null>
|
||||
}
|
||||
|
||||
async function finalizeClaudePublishedSession(
|
||||
input: CloseClaudePublishedSessionInput,
|
||||
session: ClaudeSession
|
||||
): Promise<boolean> {
|
||||
settleClaudeDispatchWaiters(session)
|
||||
// Settle every in-flight permission callback so closing leaves no dangling promise; `null`
|
||||
// writes no response, and the SDK ignores any post-cleanup answer regardless.
|
||||
for (const prompt of session.prompts.clear()) {
|
||||
prompt.settle(null)
|
||||
}
|
||||
if ((await session.connection.close()) !== true) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const transcriptLeaf = input.readTranscriptLeaf
|
||||
? await readClaudeTranscriptLeafWithReproof({
|
||||
readTranscriptLeaf: input.readTranscriptLeaf,
|
||||
providerSessionId: session.providerSessionId,
|
||||
previousLeafUuid: session.leafUuid,
|
||||
claudeConfigDir: session.claudeConfigDir
|
||||
})
|
||||
: null
|
||||
if (transcriptLeaf) {
|
||||
session.leafUuid = transcriptLeaf
|
||||
}
|
||||
} catch {
|
||||
// Keep the last observed main-transcript frame when the durable tail is
|
||||
// unavailable or proves a stale/divergent branch.
|
||||
}
|
||||
const persistence =
|
||||
session.closePersistence ??
|
||||
(session.closePersistence = (async () => {
|
||||
await input.persistHandle?.({
|
||||
sessionId: input.sessionId,
|
||||
providerSessionId: session.providerSessionId,
|
||||
leafUuid: session.leafUuid,
|
||||
fence: session.fence
|
||||
})
|
||||
})())
|
||||
const ended = {
|
||||
type: 'ended',
|
||||
sessionId: input.sessionId,
|
||||
reason: 'claude session closed'
|
||||
} as const
|
||||
let callbackError: unknown
|
||||
let callbackThrew = false
|
||||
const deliver = (event: ClaudeStructuredSessionEvent): void => {
|
||||
try {
|
||||
input.onEvent?.(event)
|
||||
} catch (error) {
|
||||
callbackThrew = true
|
||||
callbackError ??= error
|
||||
}
|
||||
}
|
||||
let persistenceError: unknown
|
||||
try {
|
||||
await persistence
|
||||
session.closeFinalized = true
|
||||
input.sessions.delete(input.sessionId)
|
||||
deliver({
|
||||
type: 'handle',
|
||||
sessionId: input.sessionId,
|
||||
providerSessionId: session.providerSessionId,
|
||||
leafUuid: session.leafUuid,
|
||||
fence: session.fence
|
||||
})
|
||||
} catch (error) {
|
||||
// Keep the closed session indexed so a retry can persist the same cursor.
|
||||
// Removing it first would turn a durable-write failure into a no-op retry.
|
||||
if (session.closePersistence === persistence) {
|
||||
session.closePersistence = undefined
|
||||
}
|
||||
persistenceError = error
|
||||
}
|
||||
// The connection already proved the child dead, so the session has ended
|
||||
// whatever the durable write did: withholding it would strand the renderer on
|
||||
// a session nothing re-drives. Emitted once, so a retry only re-persists.
|
||||
if (!session.closeEnded) {
|
||||
session.closeEnded = true
|
||||
try {
|
||||
try {
|
||||
session.translator?.handle(ended)
|
||||
} catch (error) {
|
||||
callbackThrew = true
|
||||
callbackError ??= error
|
||||
}
|
||||
deliver(ended)
|
||||
} finally {
|
||||
session.translator?.dispose()
|
||||
}
|
||||
}
|
||||
if (persistenceError) {
|
||||
throw persistenceError
|
||||
}
|
||||
if (callbackThrew) {
|
||||
throw callbackError
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function closeClaudePublishedSession(
|
||||
input: CloseClaudePublishedSessionInput
|
||||
): Promise<boolean> {
|
||||
const session = input.sessions.get(input.sessionId)
|
||||
if (!session) {
|
||||
return true
|
||||
}
|
||||
if (session.closeFinalized) {
|
||||
return true
|
||||
}
|
||||
if (session.closeFinalization) {
|
||||
return session.closeFinalization
|
||||
}
|
||||
const finalization = finalizeClaudePublishedSession(input, session)
|
||||
session.closeFinalization = finalization
|
||||
try {
|
||||
return await finalization
|
||||
} finally {
|
||||
if (session.closeFinalization === finalization && !session.closeFinalized) {
|
||||
session.closeFinalization = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeClaudePublishedSessionForDeps(
|
||||
sessions: Map<string, ClaudeSession>,
|
||||
sessionId: string,
|
||||
deps: {
|
||||
persistHandle?: (handle: {
|
||||
sessionId: string
|
||||
providerSessionId: string
|
||||
leafUuid: string | null
|
||||
fence: number
|
||||
}) => Promise<void>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
readTranscriptLeaf?: (input: {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
claudeConfigDir: string
|
||||
}) => Promise<string | null>
|
||||
}
|
||||
): Promise<boolean> {
|
||||
return closeClaudePublishedSession({ sessions, sessionId, ...deps })
|
||||
}
|
||||
|
||||
export async function closeClaudeSession(input: {
|
||||
sessionId: string
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
persistHandle?: (handle: {
|
||||
sessionId: string
|
||||
providerSessionId: string
|
||||
leafUuid: string | null
|
||||
fence: number
|
||||
}) => Promise<void>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
readTranscriptLeaf?: (input: {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
claudeConfigDir: string
|
||||
}) => Promise<string | null>
|
||||
}): Promise<boolean> {
|
||||
const attempt = input.acquisitions.get(input.sessionId)
|
||||
if (!(await cancelClaudeAcquisitionAttempt(attempt))) {
|
||||
return false
|
||||
}
|
||||
if (attempt) {
|
||||
input.acquisitions.deleteIfCurrent(input.sessionId, attempt)
|
||||
}
|
||||
return closeClaudePublishedSession(input)
|
||||
}
|
||||
|
||||
export async function closeAllClaudeSessions(input: {
|
||||
sessions: Map<string, ClaudeSession>
|
||||
acquisitions: ClaudeAcquisitionRegistry
|
||||
exits: Map<string, ClaudeSessionExit>
|
||||
closeSession: (sessionId: string) => Promise<boolean>
|
||||
closeExit: (sessionId: string) => Promise<boolean>
|
||||
}): Promise<void> {
|
||||
input.acquisitions.close()
|
||||
await closeProcessRegistry({
|
||||
attempts: 3,
|
||||
hasEntries: () =>
|
||||
input.sessions.size > 0 || input.acquisitions.size > 0 || input.exits.size > 0,
|
||||
entryIds: () =>
|
||||
new Set([
|
||||
...input.sessions.keys(),
|
||||
...input.acquisitions.sessionIds(),
|
||||
...input.exits.keys()
|
||||
]),
|
||||
closeEntry: async (sessionId) =>
|
||||
input.exits.has(sessionId) ? input.closeExit(sessionId) : input.closeSession(sessionId),
|
||||
failureMessage: 'claude structured session shutdown could not prove every child stopped'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type {
|
||||
AgentSessionModelOption,
|
||||
AgentSessionOptionChoice,
|
||||
AgentSessionOptionsResult
|
||||
} from '../../shared/agent-session-wire'
|
||||
import { CLAUDE_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex'
|
||||
import type { CatalogModel } from '../../shared/agent-session-option-catalog-types'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
type ListedModel = AgentSessionModelOption & { resolvedModel: string | null }
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's current effort, which only `get_settings` reports: the
|
||||
* `system/init` frame carries `model` but has never carried an effort of any
|
||||
* kind. Null when the provider stops reporting it, so the pill goes empty
|
||||
* rather than showing an effort nothing measured.
|
||||
*/
|
||||
export function readClaudeSettingsEffort(settings: unknown): string | null {
|
||||
return text(record(record(settings)?.effective)?.effortLevel)
|
||||
}
|
||||
|
||||
function effortLabel(value: string): string {
|
||||
return value === 'xhigh' ? 'Extra high' : `${value.charAt(0).toUpperCase()}${value.slice(1)}`
|
||||
}
|
||||
|
||||
function listedEfforts(row: Record<string, unknown>): AgentSessionOptionChoice[] {
|
||||
return row.supportsEffort === true && Array.isArray(row.supportedEffortLevels)
|
||||
? row.supportedEffortLevels.flatMap((value) => {
|
||||
const effort = text(value)
|
||||
return effort ? [{ value: effort, label: effortLabel(effort) }] : []
|
||||
})
|
||||
: []
|
||||
}
|
||||
|
||||
function listedModels(value: unknown): ListedModel[] {
|
||||
const response = record(value)
|
||||
const rows = Array.isArray(response?.models)
|
||||
? response.models.map(record).filter((row): row is Record<string, unknown> => row !== null)
|
||||
: []
|
||||
const defaultRow = rows.find((row) => text(row.value) === 'default')
|
||||
const defaultResolvedModel = text(defaultRow?.resolvedModel)
|
||||
const seen = new Set<string>()
|
||||
return rows.flatMap((row) => {
|
||||
const id = text(row.value)
|
||||
if (!id || id === 'default' || seen.has(id)) {
|
||||
return []
|
||||
}
|
||||
seen.add(id)
|
||||
const resolvedModel = text(row.resolvedModel)
|
||||
const description = text(row.description)
|
||||
return [
|
||||
{
|
||||
id,
|
||||
label: text(row.displayName) ?? id,
|
||||
...(description ? { description } : {}),
|
||||
isDefault: resolvedModel !== null && resolvedModel === defaultResolvedModel,
|
||||
efforts: listedEfforts(row),
|
||||
resolvedModel
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function seedEfforts(model: CatalogModel): AgentSessionOptionChoice[] {
|
||||
const effort = model.options.find((option) => option.id === 'effort')
|
||||
return effort?.kind.type === 'select' ? effort.kind.choices : []
|
||||
}
|
||||
|
||||
function seedModels(): ListedModel[] {
|
||||
return CLAUDE_SESSION_OPTION_CATALOG.models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
...(model.description ? { description: model.description } : {}),
|
||||
isDefault: model.isDefault === true,
|
||||
efforts: seedEfforts(model),
|
||||
resolvedModel: null
|
||||
}))
|
||||
}
|
||||
|
||||
function currentModelId(models: ListedModel[], reportedModel: string | undefined): string {
|
||||
const matched = reportedModel
|
||||
? models.find((model) => model.id === reportedModel || model.resolvedModel === reportedModel)
|
||||
: undefined
|
||||
return (
|
||||
matched?.id ?? reportedModel ?? models.find((model) => model.isDefault)?.id ?? models[0]!.id
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The model the session is running. A report the CLI made after the last write
|
||||
* outranks the write: it names the model the session ran. An older one does not
|
||||
* — a model set between turns has no report yet, and deferring to the previous
|
||||
* turn's would flip the pill back.
|
||||
*
|
||||
* Sole resolver of that question: every surface that acts on "the current model"
|
||||
* — the pill, the effort guard, the rejection it names — reads it here, so two
|
||||
* of them cannot answer it differently and offer an effort a third then refuses.
|
||||
*/
|
||||
export function readClaudeCurrentModel(session: ClaudeSession): {
|
||||
id: string | undefined
|
||||
confirmed: boolean
|
||||
} {
|
||||
const confirmed =
|
||||
session.reportedModelMutation === session.optionMutationSequence &&
|
||||
session.reportedOptions.model !== undefined
|
||||
return {
|
||||
id: confirmed
|
||||
? session.reportedOptions.model
|
||||
: (session.options.get('model') ?? session.reportedOptions.model),
|
||||
confirmed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The effort levels the session's current model advertises, with the catalog id
|
||||
* that matched so a refusal names the model the pill shows. Levels are null when
|
||||
* nothing identified the model: `apply_flag_settings` accepts and stores any
|
||||
* level for a model with no effort control, so the catalog is the only evidence
|
||||
* of a refusal — and an absent or unlisted one is not evidence, or a live CLI
|
||||
* that predates `list_models` would have every effort refused under it.
|
||||
*/
|
||||
export async function readClaudeModelEffortLevels(
|
||||
session: ClaudeSession,
|
||||
timeoutMs: number | undefined
|
||||
): Promise<{ modelId: string | undefined; levels: ReadonlySet<string> | null }> {
|
||||
const modelId = readClaudeCurrentModel(session).id
|
||||
if (!modelId) {
|
||||
return { modelId, levels: null }
|
||||
}
|
||||
const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null)
|
||||
const matched = catalog
|
||||
? listedModels({ models: catalog }).find(
|
||||
(model) => model.id === modelId || model.resolvedModel === modelId
|
||||
)
|
||||
: undefined
|
||||
return {
|
||||
modelId: matched?.id ?? modelId,
|
||||
levels: matched ? new Set(matched.efforts.map((choice) => choice.value)) : null
|
||||
}
|
||||
}
|
||||
|
||||
export async function readClaudeStructuredSessionOptions(
|
||||
session: ClaudeSession,
|
||||
timeoutMs: number | undefined
|
||||
): Promise<AgentSessionOptionsResult> {
|
||||
const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null)
|
||||
const discovered = listedModels(catalog ? { models: catalog } : null)
|
||||
const models = discovered.length > 0 ? discovered : seedModels()
|
||||
const current = readClaudeCurrentModel(session)
|
||||
const model = currentModelId(models, current.id)
|
||||
if (!models.some((entry) => entry.id === model)) {
|
||||
models.push({ id: model, label: model, isDefault: false, efforts: [], resolvedModel: null })
|
||||
}
|
||||
const effort = session.options.get('effort') ?? session.reportedOptions.effort
|
||||
const confirmed = [
|
||||
...(current.confirmed ? ['model'] : []),
|
||||
...(effort && session.confirmedOptions.has('effort') ? ['effort'] : [])
|
||||
]
|
||||
return {
|
||||
models: models.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
isDefault: entry.isDefault,
|
||||
efforts: entry.efforts
|
||||
})),
|
||||
current: {
|
||||
model,
|
||||
...(effort ? { effort } : {}),
|
||||
...(confirmed.length > 0 ? { confirmed } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { ClaudeInitObservation } from './claude-structured-init-proof'
|
||||
import { claudeProviderHandleLink } from './claude-structured-owner-identity'
|
||||
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import type { ClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
export function createClaudeSessionPublication(input: {
|
||||
connection: ClaudeSession['connection']
|
||||
init: ClaudeInitObservation
|
||||
claudeConfigDir: string
|
||||
leafUuid: string | null
|
||||
fence: number
|
||||
acquisitionGeneration: string
|
||||
resumed: boolean
|
||||
prompts: ClaudePromptRegistry
|
||||
translator: ClaudeJournalTranslator | null
|
||||
events: ClaudeSession['events']
|
||||
process: AgentSessionAcquisition['process']
|
||||
linkId?: string
|
||||
observedAt: number
|
||||
options?: ReadonlyMap<string, string>
|
||||
capabilities: readonly string[]
|
||||
/** Read from `get_settings`; `system/init` never reports an effort. */
|
||||
effort: string | null
|
||||
}): { acquisition: AgentSessionAcquisition; session: ClaudeSession } {
|
||||
const model = input.init.model
|
||||
const effort = input.effort
|
||||
return {
|
||||
acquisition: {
|
||||
process: input.process,
|
||||
link: claudeProviderHandleLink({
|
||||
sessionId: input.init.providerSessionId,
|
||||
leafUuid: input.leafUuid,
|
||||
resumed: input.resumed,
|
||||
fence: input.fence,
|
||||
...(input.linkId ? { linkId: input.linkId } : {}),
|
||||
observedAt: input.observedAt
|
||||
}),
|
||||
acquisitionGeneration: input.acquisitionGeneration
|
||||
},
|
||||
session: {
|
||||
connection: input.connection,
|
||||
providerSessionId: input.init.providerSessionId,
|
||||
claudeConfigDir: input.claudeConfigDir,
|
||||
leafUuid: input.leafUuid,
|
||||
fence: input.fence,
|
||||
acquisitionGeneration: input.acquisitionGeneration,
|
||||
prompts: input.prompts,
|
||||
dispatchWaiters: [],
|
||||
retiredDispatchWaiters: [],
|
||||
replayContentFallbackBlocked: false,
|
||||
dispatchSequence: 0,
|
||||
optionMutationSequence: 0,
|
||||
options: new Map(input.options),
|
||||
capabilities: input.capabilities,
|
||||
reportedOptions: {
|
||||
...(model ? { model } : {}),
|
||||
...(effort ? { effort } : {})
|
||||
},
|
||||
reportedModelMutation: 0,
|
||||
confirmedOptions: new Set(effort ? ['effort'] : []),
|
||||
restoreSkippedOptions: new Set(),
|
||||
translator: input.translator,
|
||||
events: input.events
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import {
|
||||
ClaudeStructuredSessionAdapter,
|
||||
type ClaudeStructuredSessionAdapterDeps,
|
||||
type ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-adapter'
|
||||
import { ClaudeTranscriptPreviousCursorMissingError } from './claude-transcript-branch-proof'
|
||||
import {
|
||||
adapterFor,
|
||||
fakeClaude,
|
||||
identityFor,
|
||||
invokeCanUseTool,
|
||||
PROVIDER_SESSION_ID,
|
||||
tick
|
||||
} from './claude-structured-session-test-support'
|
||||
|
||||
describe('ClaudeStructuredSessionAdapter transcript-derived recovery', () => {
|
||||
it('shares concurrent close finalization and emits lifecycle once', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const persistence = Promise.withResolvers<void>()
|
||||
const persistHandle = vi.fn(() => persistence.promise)
|
||||
const adapter = adapterFor(claude, {}, events, [], undefined, undefined, persistHandle)
|
||||
const journalSink: StructuredAgentSessionEventSink = {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
events: journalSink
|
||||
})
|
||||
const session = (
|
||||
adapter as unknown as {
|
||||
sessions: Map<string, { translator: { dispose: () => void } | null }>
|
||||
}
|
||||
).sessions.get('session-1')
|
||||
const disposeTranslator = vi.spyOn(session!.translator!, 'dispose')
|
||||
|
||||
const first = adapter.closeSession('session-1')
|
||||
const second = adapter.closeSession('session-1')
|
||||
await tick()
|
||||
expect(persistHandle).toHaveBeenCalledOnce()
|
||||
expect(claude.connections[0].closeCount).toBe(1)
|
||||
|
||||
persistence.resolve()
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
|
||||
expect(events.filter((event) => event.type === 'handle')).toHaveLength(1)
|
||||
expect(events.filter((event) => event.type === 'ended')).toHaveLength(1)
|
||||
expect(disposeTranslator).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still emits ended and disposes state when handle delivery throws', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const callbackError = new Error('handle delivery failed')
|
||||
const adapter = new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
pathToClaudeCodeExecutable: 'claude',
|
||||
options: {},
|
||||
cwd: '/work/repo',
|
||||
claudeConfigDir: '/accounts/claude',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
resumeLeafUuid: null,
|
||||
resumed: false
|
||||
}),
|
||||
onEvent: (event) => {
|
||||
events.push(event)
|
||||
if (event.type === 'handle') {
|
||||
throw callbackError
|
||||
}
|
||||
},
|
||||
openConnection: claude.openConnection,
|
||||
readProcessStartTime: async () => 1_700_000_000_000,
|
||||
persistHandle: vi.fn(async () => undefined)
|
||||
})
|
||||
const journalSink: StructuredAgentSessionEventSink = {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
events: journalSink
|
||||
})
|
||||
const session = (
|
||||
adapter as unknown as {
|
||||
sessions: Map<string, { translator: { dispose: () => void } | null }>
|
||||
}
|
||||
).sessions.get('session-1')
|
||||
const disposeTranslator = vi.spyOn(session!.translator!, 'dispose')
|
||||
|
||||
await expect(adapter.closeSession('session-1')).rejects.toBe(callbackError)
|
||||
expect(events.filter((event) => event.type === 'handle')).toHaveLength(1)
|
||||
expect(events.filter((event) => event.type === 'ended')).toHaveLength(1)
|
||||
expect(disposeTranslator).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retains a closed session until its durable cursor persistence succeeds', async () => {
|
||||
const claude = fakeClaude()
|
||||
const persistenceError = new Error('store unavailable')
|
||||
const persistHandle = vi
|
||||
.fn<NonNullable<ClaudeStructuredSessionAdapterDeps['persistHandle']>>()
|
||||
.mockRejectedValueOnce(persistenceError)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const adapter = adapterFor(claude, {}, [], [], undefined, undefined, persistHandle)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
|
||||
await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError)
|
||||
expect(persistHandle).toHaveBeenCalledTimes(1)
|
||||
await expect(adapter.closeSession('session-1')).resolves.toBe(true)
|
||||
expect(persistHandle).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('persists only the last transcript-entry uuid before graceful close', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const persistedHandles: unknown[] = []
|
||||
const adapter = adapterFor(claude, {}, events, persistedHandles)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'assistant-leaf'
|
||||
})
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'result',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'result-frame-uuid'
|
||||
})
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'stream_event',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'stream-event-frame-uuid'
|
||||
})
|
||||
|
||||
await adapter.closeSession('session-1')
|
||||
|
||||
expect(persistedHandles).toEqual([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: 'assistant-leaf',
|
||||
fence: 7
|
||||
}
|
||||
])
|
||||
expect(events.at(-2)).toEqual({
|
||||
type: 'handle',
|
||||
sessionId: 'session-1',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: 'assistant-leaf',
|
||||
fence: 7
|
||||
})
|
||||
expect(claude.connections[0].closeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('prefers a validated durable transcript leaf at graceful close', async () => {
|
||||
const claude = fakeClaude()
|
||||
const persistedHandles: unknown[] = []
|
||||
const readTranscriptLeaf = vi.fn().mockResolvedValue('durable-tail')
|
||||
const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'observed-tail'
|
||||
})
|
||||
|
||||
await adapter.closeSession('session-1')
|
||||
|
||||
expect(readTranscriptLeaf).toHaveBeenCalledWith({
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
previousLeafUuid: 'observed-tail',
|
||||
claudeConfigDir: '/accounts/claude'
|
||||
})
|
||||
expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'durable-tail' })
|
||||
})
|
||||
|
||||
it('passes the pinned Claude account home to transcript validation', async () => {
|
||||
const claude = fakeClaude()
|
||||
const persistedHandles: unknown[] = []
|
||||
const readTranscriptLeaf = vi.fn().mockResolvedValue('durable-tail')
|
||||
const adapter = adapterFor(
|
||||
claude,
|
||||
{ claudeConfigDir: '/accounts/selected' },
|
||||
[],
|
||||
persistedHandles,
|
||||
undefined,
|
||||
readTranscriptLeaf
|
||||
)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'observed-tail'
|
||||
})
|
||||
|
||||
await adapter.closeSession('session-1')
|
||||
|
||||
expect(readTranscriptLeaf).toHaveBeenCalledWith({
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
previousLeafUuid: 'observed-tail',
|
||||
claudeConfigDir: '/accounts/selected'
|
||||
})
|
||||
})
|
||||
|
||||
it('re-proves from the transcript root when the observed cursor is missing', async () => {
|
||||
const claude = fakeClaude()
|
||||
const persistedHandles: unknown[] = []
|
||||
const readTranscriptLeaf = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new ClaudeTranscriptPreviousCursorMissingError())
|
||||
.mockResolvedValueOnce('reproved-main-leaf')
|
||||
const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'observed-tail'
|
||||
})
|
||||
|
||||
await adapter.closeSession('session-1')
|
||||
|
||||
expect(readTranscriptLeaf).toHaveBeenNthCalledWith(1, {
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
previousLeafUuid: 'observed-tail',
|
||||
claudeConfigDir: '/accounts/claude'
|
||||
})
|
||||
expect(readTranscriptLeaf).toHaveBeenNthCalledWith(2, {
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
previousLeafUuid: null,
|
||||
claudeConfigDir: '/accounts/claude'
|
||||
})
|
||||
expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'reproved-main-leaf' })
|
||||
})
|
||||
|
||||
it('keeps the observed leaf when transcript validation proves a sibling branch', async () => {
|
||||
const claude = fakeClaude()
|
||||
const persistedHandles: unknown[] = []
|
||||
const readTranscriptLeaf = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('latest marker is on a sibling branch'))
|
||||
const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'observed-tail'
|
||||
})
|
||||
|
||||
await adapter.closeSession('session-1')
|
||||
|
||||
expect(readTranscriptLeaf).toHaveBeenCalledTimes(1)
|
||||
expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'observed-tail' })
|
||||
})
|
||||
|
||||
it('persists the last transcript leaf before an unexpected first-hand exit', async () => {
|
||||
const claude = fakeClaude()
|
||||
const persistedHandles: unknown[] = []
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = adapterFor(claude, {}, events, persistedHandles)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'crash-leaf'
|
||||
})
|
||||
|
||||
claude.connections[0].handlers.onExit?.(
|
||||
new Error('claude stream-json exited (code 1): crashed unexpectedly')
|
||||
)
|
||||
await tick()
|
||||
|
||||
expect(persistedHandles).toContainEqual({
|
||||
sessionId: 'session-1',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: 'crash-leaf',
|
||||
fence: 7
|
||||
})
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'ended',
|
||||
cause: 'unexpected-exit',
|
||||
fence: 7,
|
||||
acquisitionGeneration: expect.any(String)
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the crash cursor from the validated transcript tail', async () => {
|
||||
const claude = fakeClaude()
|
||||
const persistedHandles: unknown[] = []
|
||||
const adapter = adapterFor(
|
||||
claude,
|
||||
{},
|
||||
[],
|
||||
persistedHandles,
|
||||
undefined,
|
||||
vi.fn().mockResolvedValue('durable-crash-leaf')
|
||||
)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'stale-observed-tail'
|
||||
})
|
||||
claude.connections[0].handlers.onExit?.(
|
||||
new Error('claude stream-json exited (signal SIGKILL): crashed')
|
||||
)
|
||||
await tick()
|
||||
|
||||
expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'durable-crash-leaf' })
|
||||
})
|
||||
|
||||
it('re-proves a first-hand crash cursor from the transcript root after stale validation', async () => {
|
||||
const claude = fakeClaude()
|
||||
const persistedHandles: unknown[] = []
|
||||
const readTranscriptLeaf = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new ClaudeTranscriptPreviousCursorMissingError())
|
||||
.mockResolvedValueOnce('reproved-crash-leaf')
|
||||
const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'stale-observed-tail'
|
||||
})
|
||||
claude.connections[0].handlers.onExit?.(new Error('crashed'))
|
||||
await tick()
|
||||
|
||||
expect(readTranscriptLeaf).toHaveBeenNthCalledWith(1, {
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
previousLeafUuid: 'stale-observed-tail',
|
||||
claudeConfigDir: '/accounts/claude'
|
||||
})
|
||||
expect(readTranscriptLeaf).toHaveBeenNthCalledWith(2, {
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
previousLeafUuid: null,
|
||||
claudeConfigDir: '/accounts/claude'
|
||||
})
|
||||
expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'reproved-crash-leaf' })
|
||||
})
|
||||
|
||||
it('keeps the observed crash leaf when transcript validation proves a sibling branch', async () => {
|
||||
const claude = fakeClaude()
|
||||
const persistedHandles: unknown[] = []
|
||||
const readTranscriptLeaf = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('latest marker is on a sibling branch'))
|
||||
const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'observed-crash-tail'
|
||||
})
|
||||
claude.connections[0].handlers.onExit?.(new Error('crashed'))
|
||||
await tick()
|
||||
|
||||
expect(readTranscriptLeaf).toHaveBeenCalledTimes(1)
|
||||
expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'observed-crash-tail' })
|
||||
})
|
||||
|
||||
it('publishes lifecycle recovery even when crash-cursor persistence fails', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = adapterFor(
|
||||
claude,
|
||||
{},
|
||||
events,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
vi.fn().mockRejectedValue(new Error('store unavailable'))
|
||||
)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
|
||||
claude.connections[0].handlers.onExit?.(new Error('crashed'))
|
||||
await tick()
|
||||
|
||||
expect(events.at(-1)).toMatchObject({ type: 'ended', cause: 'unexpected-exit' })
|
||||
})
|
||||
|
||||
it('runs the child close proof before publishing unexpected-exit recovery', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = adapterFor(claude, {}, events)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
const close = vi.spyOn(claude.connections[0], 'close').mockResolvedValue(true)
|
||||
|
||||
claude.connections[0].handlers.onExit?.(new Error('crashed'))
|
||||
await tick()
|
||||
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
expect(events.at(-1)).toMatchObject({ type: 'ended', cause: 'unexpected-exit' })
|
||||
})
|
||||
|
||||
it('does not publish recovery while an unexpected-exit close proof is false', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const persistedHandles: unknown[] = []
|
||||
const adapter = adapterFor(claude, {}, events, persistedHandles)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].close = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true) as unknown as (typeof claude.connections)[0]['close']
|
||||
|
||||
claude.connections[0].handlers.onExit?.(new Error('crashed'))
|
||||
await tick()
|
||||
|
||||
expect(events.filter((event) => event.type === 'ended')).toEqual([])
|
||||
expect(persistedHandles).toEqual([])
|
||||
})
|
||||
|
||||
it('retains pending prompts while an unexpected-exit proof is unproven', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = adapterFor(claude, {}, events)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1')
|
||||
claude.connections[0].close = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValue(false) as unknown as (typeof claude.connections)[0]['close']
|
||||
|
||||
claude.connections[0].handlers.onExit?.(new Error('crashed'))
|
||||
await tick()
|
||||
|
||||
expect(answered.settled()).toBe(false)
|
||||
expect(events.filter((event) => event.type === 'ended')).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes unexpected recovery exactly once after a retained proof retries successfully', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = adapterFor(claude, {}, events)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
claude.connections[0].close = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true) as unknown as (typeof claude.connections)[0]['close']
|
||||
|
||||
claude.connections[0].handlers.onExit?.(new Error('crashed'))
|
||||
await tick()
|
||||
await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true)
|
||||
await tick()
|
||||
|
||||
expect(events.filter((event) => event.type === 'ended')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('launches the first replacement from the settled retained transcript cursor', async () => {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const persistedHandles: unknown[] = []
|
||||
const journalSink: StructuredAgentSessionEventSink = {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
const readTranscriptLeaf = vi.fn().mockResolvedValue('durable-retained-leaf')
|
||||
let durableLeafUuid: string | null = null
|
||||
const resolveLaunch = vi.fn(async ({ identity }) => {
|
||||
if (
|
||||
identity.providerHandle.kind !== 'claude' ||
|
||||
identity.providerHandle.sessionId !== PROVIDER_SESSION_ID ||
|
||||
identity.providerHandle.leafUuid !== durableLeafUuid
|
||||
) {
|
||||
throw new Error('claude durable resume identity changed before spawn')
|
||||
}
|
||||
if (durableLeafUuid === null) {
|
||||
return {
|
||||
pathToClaudeCodeExecutable: 'claude',
|
||||
options: { sessionId: PROVIDER_SESSION_ID },
|
||||
cwd: '/work/repo',
|
||||
claudeConfigDir: '/accounts/claude',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
resumeLeafUuid: null,
|
||||
resumed: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
pathToClaudeCodeExecutable: 'claude',
|
||||
options: { resume: PROVIDER_SESSION_ID, resumeSessionAt: durableLeafUuid },
|
||||
cwd: '/work/repo',
|
||||
claudeConfigDir: '/accounts/claude',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
resumeLeafUuid: durableLeafUuid,
|
||||
resumed: true
|
||||
}
|
||||
})
|
||||
const persistHandle = vi.fn<NonNullable<ClaudeStructuredSessionAdapterDeps['persistHandle']>>(
|
||||
async (handle) => {
|
||||
durableLeafUuid = handle.leafUuid
|
||||
persistedHandles.push(handle)
|
||||
}
|
||||
)
|
||||
const adapter = new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch,
|
||||
openConnection: claude.openConnection,
|
||||
onEvent: (event) => events.push(event),
|
||||
readProcessStartTime: async () => 1_700_000_000_000,
|
||||
now: () => 1_700_000_000_500,
|
||||
readTranscriptLeaf,
|
||||
persistHandle
|
||||
})
|
||||
const firstAcquisition = await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
events: journalSink
|
||||
})
|
||||
const first = claude.connections[0]
|
||||
const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained')
|
||||
const oldSession = (
|
||||
adapter as unknown as {
|
||||
sessions: Map<
|
||||
string,
|
||||
{
|
||||
translator: { dispose: () => void } | null
|
||||
prompts: {
|
||||
find: (itemId: string) => { prompt: { settle: (value: unknown) => void } } | null
|
||||
}
|
||||
}
|
||||
>
|
||||
}
|
||||
).sessions.get('session-1')
|
||||
expect(oldSession?.translator).not.toBeNull()
|
||||
const disposeTranslator = vi.spyOn(oldSession!.translator!, 'dispose')
|
||||
const pendingPrompt = oldSession?.prompts.find('permission-retained')
|
||||
expect(pendingPrompt).not.toBeNull()
|
||||
const settlePrompt = vi.spyOn(pendingPrompt!.prompt, 'settle')
|
||||
first.handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'observed-retained-leaf'
|
||||
})
|
||||
first.close = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true) as unknown as (typeof first)['close']
|
||||
first.handlers.onExit?.(new Error('crashed before replacement'))
|
||||
await tick()
|
||||
|
||||
expect(oldPrompt.settled()).toBe(false)
|
||||
expect(events.filter((event) => event.type === 'ended')).toEqual([])
|
||||
|
||||
const replacement = await adapter.acquire({
|
||||
identity: {
|
||||
...identityFor(),
|
||||
providerHandle: {
|
||||
kind: 'claude',
|
||||
sessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: 'observed-retained-leaf'
|
||||
}
|
||||
},
|
||||
fence: 8,
|
||||
spawnToken: 'spawn-10',
|
||||
events: journalSink
|
||||
})
|
||||
|
||||
expect(disposeTranslator).toHaveBeenCalledOnce()
|
||||
expect(settlePrompt).toHaveBeenCalledOnce()
|
||||
expect(settlePrompt).toHaveBeenCalledWith(null)
|
||||
expect(persistHandle).toHaveBeenCalledOnce()
|
||||
expect(persistedHandles).toEqual([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: 'durable-retained-leaf',
|
||||
fence: 7
|
||||
}
|
||||
])
|
||||
expect(readTranscriptLeaf).toHaveBeenCalledOnce()
|
||||
expect(readTranscriptLeaf).toHaveBeenCalledWith({
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
previousLeafUuid: 'observed-retained-leaf',
|
||||
claudeConfigDir: '/accounts/claude'
|
||||
})
|
||||
expect(resolveLaunch).toHaveBeenNthCalledWith(2, {
|
||||
identity: {
|
||||
...identityFor(),
|
||||
providerHandle: {
|
||||
kind: 'claude',
|
||||
sessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: 'durable-retained-leaf'
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(oldPrompt.settled()).toBe(true)
|
||||
expect(events.filter((event) => event.type === 'ended')).toEqual([
|
||||
{
|
||||
type: 'ended',
|
||||
sessionId: 'session-1',
|
||||
reason: 'crashed before replacement',
|
||||
cause: 'unexpected-exit',
|
||||
fence: 7,
|
||||
acquisitionGeneration: firstAcquisition.acquisitionGeneration
|
||||
}
|
||||
])
|
||||
expect(replacement.link).toMatchObject({
|
||||
handle: {
|
||||
provider: 'claude',
|
||||
sessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: 'durable-retained-leaf'
|
||||
},
|
||||
origin: 'resumed',
|
||||
mintedAtFence: 8
|
||||
})
|
||||
expect(claude.connections[1]?.launch.options).toMatchObject({
|
||||
resume: PROVIDER_SESSION_ID,
|
||||
resumeSessionAt: 'durable-retained-leaf'
|
||||
})
|
||||
expect(claude.connections).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type {
|
||||
ClaudeStreamJsonConnection,
|
||||
openClaudeStreamJsonConnection
|
||||
} from './claude-stream-json-connection'
|
||||
import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
|
||||
import type { ClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export type ClaudeAuthDiagnostic = {
|
||||
apiKeySourceConfigured: boolean
|
||||
baseUrlConfigured: boolean
|
||||
authTokenConfigured: boolean
|
||||
apiKeyConfigured: boolean
|
||||
settingSources: readonly string[]
|
||||
}
|
||||
|
||||
export type ClaudeStructuredSessionEvent =
|
||||
| {
|
||||
type: 'message'
|
||||
sessionId: string
|
||||
message: Record<string, unknown>
|
||||
/** Present only when this replay acknowledged Orca's in-flight dispatch. */
|
||||
startsTurn?: true
|
||||
}
|
||||
| { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown }
|
||||
| { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt }
|
||||
| { type: 'prompt-cancelled'; sessionId: string; promptKey: string }
|
||||
| { type: 'options'; sessionId: string; models: unknown[] }
|
||||
| {
|
||||
type: 'handle'
|
||||
sessionId: string
|
||||
providerSessionId: string
|
||||
leafUuid: string | null
|
||||
fence: number
|
||||
}
|
||||
| { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic }
|
||||
| {
|
||||
type: 'ended'
|
||||
sessionId: string
|
||||
reason: string
|
||||
/** Present for first-hand child exits so the host can fence recovery. */
|
||||
cause?: 'unexpected-exit' | 'requested-close'
|
||||
fence?: number
|
||||
acquisitionGeneration?: string
|
||||
settlementRetryRequired?: boolean
|
||||
}
|
||||
|
||||
export type ClaudeStructuredSessionAdapterDeps = {
|
||||
resolveLaunch: (input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
}) => Promise<ClaudeStructuredLaunch>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
openConnection?: typeof openClaudeStreamJsonConnection
|
||||
readProcessStartTime?: (pid: number) => Promise<number | null>
|
||||
mintLinkId?: () => string
|
||||
mintAcquisitionGeneration?: () => string
|
||||
now?: () => number
|
||||
requestTimeoutMs?: number
|
||||
initTimeoutMs?: number
|
||||
dispatchAckTimeoutMs?: number
|
||||
persistHandle?: (input: {
|
||||
sessionId: string
|
||||
providerSessionId: string
|
||||
leafUuid: string | null
|
||||
fence: number
|
||||
}) => Promise<void>
|
||||
/** Read the durable transcript branch after a child has flushed its final rows. */
|
||||
readTranscriptLeaf?: (input: {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
/** Account-scoped Claude config root that owns this provider session. */
|
||||
claudeConfigDir: string
|
||||
}) => Promise<string | null>
|
||||
}
|
||||
|
||||
export type ClaudeDispatchWaiter = {
|
||||
resolve: (uuid: string | null) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
acceptsResult: boolean
|
||||
/** Client uuid echoed by Claude so a replay is tied to its own dispatch. */
|
||||
sentUuid: string
|
||||
/** Sequence used to fence a late identity from a newer dispatch. */
|
||||
dispatchSequence: number
|
||||
/** Set when the provider replay settled this waiter before send returned. */
|
||||
settledUuid?: string
|
||||
/** The waiter timed out or its write failed, but its replay may still arrive. */
|
||||
retired?: boolean
|
||||
/** Bounded digest/summary for compatibility CLIs that mint UUIDs. */
|
||||
replayContentKey: string
|
||||
}
|
||||
|
||||
export type ClaudeSession = {
|
||||
connection: ClaudeStreamJsonConnection
|
||||
providerSessionId: string
|
||||
/** Durable transcript files live under this account's `projects` directory. */
|
||||
claudeConfigDir: string
|
||||
leafUuid: string | null
|
||||
fence: number
|
||||
acquisitionGeneration: string
|
||||
prompts: ClaudePromptRegistry
|
||||
dispatchWaiters: ClaudeDispatchWaiter[]
|
||||
/** Bounded identities for dispatches whose ack was unknown when they returned. */
|
||||
retiredDispatchWaiters: ClaudeDispatchWaiter[]
|
||||
/** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */
|
||||
replayContentFallbackBlocked: boolean
|
||||
options: Map<string, string>
|
||||
reportedOptions: { model?: string; effort?: string }
|
||||
/** `optionMutationSequence` when `reportedOptions.model` was last observed, so a
|
||||
* write still awaiting its first turn outranks the report it will replace. */
|
||||
reportedModelMutation: number
|
||||
/** Options whose recorded value the provider reported, not merely accepted. */
|
||||
confirmedOptions: Set<string>
|
||||
restoreSkippedOptions: Set<string>
|
||||
/** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */
|
||||
capabilities: readonly string[]
|
||||
/** Provider uuid of the most recently admitted turn, if one is active. */
|
||||
activeTurnId?: string
|
||||
/** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */
|
||||
dispatchSequence: number
|
||||
/** Dispatch sequence that admitted activeTurnId. */
|
||||
activeTurnSequence?: number
|
||||
/** Fences overlapping option writes so a late completion cannot restore stale state. */
|
||||
optionMutationSequence: number
|
||||
/** Shared durable-close write; a failed write clears this for a retry. */
|
||||
closePersistence?: Promise<void>
|
||||
/** Shared full close/finalization operation; a failed operation clears this for a retry. */
|
||||
closeFinalization?: Promise<boolean>
|
||||
/** Set only after the durable close write succeeds, before lifecycle emission. */
|
||||
closeFinalized?: boolean
|
||||
/** Set once `ended` has been emitted, so a persistence retry cannot repeat it. */
|
||||
closeEnded?: boolean
|
||||
translator: ClaudeJournalTranslator | null
|
||||
events: StructuredAgentSessionEventSink | undefined
|
||||
}
|
||||
|
||||
export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string {
|
||||
return deps.mintAcquisitionGeneration?.() ?? randomUUID()
|
||||
}
|
||||
|
||||
/**
|
||||
* The first-hand exit that removed a published session. Kept until the session
|
||||
* is acquired again so acquisition cleanup that arrives after the exit finds
|
||||
* what the ladder observed, not an absence it would otherwise report as proven.
|
||||
*/
|
||||
export type ClaudeSessionExit = {
|
||||
connection: ClaudeStreamJsonConnection
|
||||
/** Full session identity retained until its child tree is proven gone. */
|
||||
session: ClaudeSession
|
||||
error: Error
|
||||
/** The exit path's first proof attempt; retries must observe this result. */
|
||||
closePromise?: Promise<boolean>
|
||||
/** Shared lifecycle settlement for concurrent proof retries. */
|
||||
settlementPromise?: Promise<void>
|
||||
}
|
||||
|
||||
export type ClaudeAcquisitionAttempt = {
|
||||
connection: ClaudeStreamJsonConnection | null
|
||||
prompts: ClaudePromptRegistry
|
||||
buffered: (() => void)[]
|
||||
published: boolean
|
||||
cancelled: boolean
|
||||
exitProven: boolean
|
||||
finished: Promise<void>
|
||||
finish: () => void
|
||||
}
|
||||
|
||||
export function createClaudeAcquisitionAttempt(
|
||||
prompts: ClaudePromptRegistry
|
||||
): ClaudeAcquisitionAttempt {
|
||||
let finish = (): void => {}
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return {
|
||||
connection: null,
|
||||
prompts,
|
||||
buffered: [],
|
||||
published: false,
|
||||
cancelled: false,
|
||||
exitProven: false,
|
||||
finished,
|
||||
finish
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeAcquisitionRegistry {
|
||||
private readonly attempts = new Map<string, ClaudeAcquisitionAttempt>()
|
||||
private closing = false
|
||||
|
||||
get size(): number {
|
||||
return this.attempts.size
|
||||
}
|
||||
|
||||
start(
|
||||
sessionId: string,
|
||||
prompts: ClaudePromptRegistry
|
||||
): {
|
||||
previous: ClaudeAcquisitionAttempt | undefined
|
||||
attempt: ClaudeAcquisitionAttempt
|
||||
} {
|
||||
if (this.closing) {
|
||||
throw new Error('claude structured session adapter is closing')
|
||||
}
|
||||
const previous = this.attempts.get(sessionId)
|
||||
const attempt = createClaudeAcquisitionAttempt(prompts)
|
||||
this.attempts.set(sessionId, attempt)
|
||||
return { previous, attempt }
|
||||
}
|
||||
|
||||
assertCurrent(sessionId: string, attempt: ClaudeAcquisitionAttempt): void {
|
||||
if (this.closing || attempt.cancelled || this.attempts.get(sessionId) !== attempt) {
|
||||
throw new Error(`claude session ${sessionId} was superseded while being acquired`)
|
||||
}
|
||||
}
|
||||
|
||||
get(sessionId: string): ClaudeAcquisitionAttempt | undefined {
|
||||
return this.attempts.get(sessionId)
|
||||
}
|
||||
|
||||
deleteIfCurrent(sessionId: string, attempt: ClaudeAcquisitionAttempt): void {
|
||||
if (this.attempts.get(sessionId) === attempt) {
|
||||
this.attempts.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
restoreIfCurrent(
|
||||
sessionId: string,
|
||||
replacement: ClaudeAcquisitionAttempt,
|
||||
previous: ClaudeAcquisitionAttempt
|
||||
): void {
|
||||
if (this.attempts.get(sessionId) === replacement) {
|
||||
this.attempts.set(sessionId, previous)
|
||||
}
|
||||
}
|
||||
|
||||
sessionIds(): IterableIterator<string> {
|
||||
return this.attempts.keys()
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closing = true
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelClaudeAcquisitionAttempt(
|
||||
attempt: ClaudeAcquisitionAttempt | undefined
|
||||
): Promise<boolean> {
|
||||
if (!attempt) {
|
||||
return true
|
||||
}
|
||||
return cancelProcessAcquisition({
|
||||
cancel: () => {
|
||||
attempt.cancelled = true
|
||||
},
|
||||
connection: () => attempt.connection,
|
||||
exitProven: () => attempt.exitProven,
|
||||
finished: attempt.finished
|
||||
})
|
||||
}
|
||||
|
||||
/** What an acquisition hands back to the adapter that owns the session map:
|
||||
* event delivery ordered against publication, and the two exit settlements. */
|
||||
export type ClaudeAcquireCallbacks = {
|
||||
deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void
|
||||
emit: (
|
||||
session: ClaudeSession | null,
|
||||
events: StructuredAgentSessionEventSink | undefined,
|
||||
event: ClaudeStructuredSessionEvent
|
||||
) => void
|
||||
handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void
|
||||
settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type {
|
||||
AgentJournalMessageItem,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
ClaudeStreamJsonConnection,
|
||||
ClaudeStreamJsonConnectionHandlers,
|
||||
ClaudeStreamJsonLaunch,
|
||||
openClaudeStreamJsonConnection
|
||||
} from './claude-stream-json-connection'
|
||||
import {
|
||||
ClaudeStructuredSessionAdapter,
|
||||
type ClaudeStructuredSessionAdapterDeps,
|
||||
type ClaudeStructuredLaunch,
|
||||
type ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-adapter'
|
||||
|
||||
export const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a'
|
||||
|
||||
export const USER_MESSAGE: AgentJournalMessageItem = {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'ship it' }]
|
||||
}
|
||||
|
||||
export function identityFor(sessionId = 'session-1'): AgentSessionJournalIdentity {
|
||||
return {
|
||||
sessionId,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }
|
||||
}
|
||||
}
|
||||
|
||||
type Route = (params: Record<string, unknown> | undefined) => unknown
|
||||
|
||||
export type FakeConnection = Omit<ClaudeStreamJsonConnection, 'closed' | 'exitVerdict'> & {
|
||||
closed: boolean
|
||||
exitVerdict: ClaudeStreamJsonConnection['exitVerdict']
|
||||
launch: ClaudeStreamJsonLaunch
|
||||
handlers: ClaudeStreamJsonConnectionHandlers
|
||||
calls: { subtype: string; params?: Record<string, unknown> }[]
|
||||
sent: Record<string, unknown>[]
|
||||
closeCount: number
|
||||
}
|
||||
|
||||
export function fakeClaude(
|
||||
options: {
|
||||
initSessionId?: string
|
||||
initUuid?: string
|
||||
initModel?: string
|
||||
initProof?: 'init' | 'session-start' | 'none'
|
||||
initAccount?: unknown
|
||||
exitBeforeInit?: string
|
||||
settings?: unknown
|
||||
replayUuid?: string | null
|
||||
replayUuids?: (string | null)[]
|
||||
capabilities?: string[]
|
||||
unprovenCloseVerdict?: ClaudeStreamJsonConnection['exitVerdict']
|
||||
routes?: Record<string, Route>
|
||||
} = {}
|
||||
): {
|
||||
connections: FakeConnection[]
|
||||
openConnection: typeof openClaudeStreamJsonConnection
|
||||
routes: Record<string, Route>
|
||||
} {
|
||||
const connections: FakeConnection[] = []
|
||||
const routes = options.routes ?? {}
|
||||
let replayIndex = 0
|
||||
const routed = (subtype: string, params?: Record<string, unknown>): unknown => {
|
||||
const route = routes[subtype]
|
||||
return route ? route(params) : undefined
|
||||
}
|
||||
const openConnection = (async (launch, handlers = {}) => {
|
||||
const connection: FakeConnection = {
|
||||
launch,
|
||||
handlers,
|
||||
calls: [],
|
||||
sent: [],
|
||||
closeCount: 0,
|
||||
pid: 4321,
|
||||
closed: false,
|
||||
initializationResult: async () => {
|
||||
connection.calls.push({ subtype: 'initialize' })
|
||||
if (options.exitBeforeInit) {
|
||||
handlers.onExit?.(new Error(options.exitBeforeInit))
|
||||
return { models: [] }
|
||||
}
|
||||
if (options.initProof === 'session-start') {
|
||||
handlers.onMessage?.({
|
||||
type: 'system',
|
||||
subtype: 'hook_started',
|
||||
hook_name: 'SessionStart:startup',
|
||||
session_id: options.initSessionId ?? PROVIDER_SESSION_ID,
|
||||
uuid: options.initUuid ?? 'init-uuid'
|
||||
})
|
||||
} else if (options.initProof !== 'none') {
|
||||
// Keys mirror the real system/init frame, which carries `model` but no
|
||||
// effort of any kind: the current effort only comes back from
|
||||
// get_settings. Never add a field the CLI does not send.
|
||||
handlers.onMessage?.({
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
session_id: options.initSessionId ?? PROVIDER_SESSION_ID,
|
||||
uuid: options.initUuid ?? 'init-uuid',
|
||||
model: options.initModel ?? 'claude-sonnet-5',
|
||||
apiKeySource: 'none',
|
||||
...(options.capabilities ? { capabilities: options.capabilities } : {})
|
||||
})
|
||||
}
|
||||
return {
|
||||
models: [{ value: 'claude-sonnet', displayName: 'Sonnet' }],
|
||||
...(options.initAccount === undefined ? {} : { account: options.initAccount })
|
||||
}
|
||||
},
|
||||
getSettings: async () => {
|
||||
connection.calls.push({ subtype: 'get_settings' })
|
||||
// Shape measured from Claude Code 2.1.258: {applied, effective, sources},
|
||||
// and the only place the session's current effort is reported.
|
||||
return (
|
||||
options.settings ?? {
|
||||
applied: { model: 'claude-sonnet-5', effort: 'high', advisor: null, ultracode: false },
|
||||
effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} },
|
||||
sources: {}
|
||||
}
|
||||
)
|
||||
},
|
||||
supportedModels: async () => {
|
||||
connection.calls.push({ subtype: 'list_models' })
|
||||
return (routed('list_models') as unknown[] | undefined) ?? []
|
||||
},
|
||||
setModel: async (model) => {
|
||||
connection.calls.push({ subtype: 'set_model', params: { model } })
|
||||
routed('set_model', { model })
|
||||
},
|
||||
setPermissionMode: async (mode) => {
|
||||
connection.calls.push({ subtype: 'set_permission_mode', params: { mode } })
|
||||
routed('set_permission_mode', { mode })
|
||||
},
|
||||
applyFlagSettings: async (settings) => {
|
||||
connection.calls.push({ subtype: 'apply_flag_settings', params: { settings } })
|
||||
routed('apply_flag_settings', { settings })
|
||||
},
|
||||
interrupt: async (interruptOptions) => {
|
||||
connection.calls.push({
|
||||
subtype: 'interrupt',
|
||||
params: interruptOptions?.cancelQueued ? { cancelQueued: true } : {}
|
||||
})
|
||||
return routed('interrupt', interruptOptions) as
|
||||
| Awaited<ReturnType<ClaudeStreamJsonConnection['interrupt']>>
|
||||
| undefined
|
||||
},
|
||||
cancelAsyncMessage: async (uuid) => {
|
||||
connection.calls.push({ subtype: 'cancel_async_message', params: { uuid } })
|
||||
routed('cancel_async_message', { uuid })
|
||||
},
|
||||
send: async (message) => {
|
||||
connection.sent.push(message)
|
||||
if (message.type === 'user' && options.replayUuid !== null) {
|
||||
const configuredReplayUuid = options.replayUuids
|
||||
? options.replayUuids[replayIndex++]
|
||||
: options.replayUuid
|
||||
const replayUuid =
|
||||
configuredReplayUuid === undefined ? `user-uuid-${replayIndex}` : configuredReplayUuid
|
||||
if (replayUuid !== null) {
|
||||
handlers.onMessage?.({
|
||||
...message,
|
||||
uuid: replayUuid
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
exitVerdict: options.unprovenCloseVerdict ?? { root: 'live', tree: 'unverifiable' },
|
||||
close: async () => {
|
||||
connection.closeCount += 1
|
||||
connection.closed = true
|
||||
return options.unprovenCloseVerdict === undefined
|
||||
}
|
||||
}
|
||||
connections.push(connection)
|
||||
return connection
|
||||
}) as typeof openClaudeStreamJsonConnection
|
||||
return { connections, openConnection, routes }
|
||||
}
|
||||
|
||||
export function adapterFor(
|
||||
claude: ReturnType<typeof fakeClaude>,
|
||||
launch: Partial<ClaudeStructuredLaunch> = {},
|
||||
events: ClaudeStructuredSessionEvent[] = [],
|
||||
persistedHandles: unknown[] = [],
|
||||
initTimeoutMs?: number,
|
||||
readTranscriptLeaf?: ClaudeStructuredSessionAdapterDeps['readTranscriptLeaf'],
|
||||
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle']
|
||||
): ClaudeStructuredSessionAdapter {
|
||||
return new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
pathToClaudeCodeExecutable: 'claude',
|
||||
options: {},
|
||||
cwd: '/work/repo',
|
||||
claudeConfigDir: '/accounts/claude',
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
resumeLeafUuid: null,
|
||||
resumed: false,
|
||||
...launch
|
||||
}),
|
||||
onEvent: (event) => events.push(event),
|
||||
openConnection: claude.openConnection,
|
||||
readProcessStartTime: async () => 1_700_000_000_000,
|
||||
now: () => 1_700_000_000_500,
|
||||
...(initTimeoutMs === undefined ? {} : { initTimeoutMs }),
|
||||
dispatchAckTimeoutMs: 10,
|
||||
persistHandle:
|
||||
persistHandle ??
|
||||
(async (handle) => {
|
||||
persistedHandles.push(handle)
|
||||
}),
|
||||
...(readTranscriptLeaf ? { readTranscriptLeaf } : {})
|
||||
})
|
||||
}
|
||||
|
||||
export async function acquired(
|
||||
claude: ReturnType<typeof fakeClaude>,
|
||||
launch: Partial<ClaudeStructuredLaunch> = {},
|
||||
events: ClaudeStructuredSessionEvent[] = []
|
||||
): Promise<ClaudeStructuredSessionAdapter> {
|
||||
const adapter = adapterFor(claude, launch, events)
|
||||
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
|
||||
return adapter
|
||||
}
|
||||
|
||||
export function tick(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
export function invokeCanUseTool(
|
||||
connection: FakeConnection,
|
||||
toolName: string,
|
||||
requestId: string,
|
||||
toolUseID: string,
|
||||
extra: {
|
||||
input?: Record<string, unknown>
|
||||
suggestions?: unknown[]
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
): { promise: Promise<unknown>; settled: () => boolean } {
|
||||
const options = {
|
||||
requestId,
|
||||
toolUseID,
|
||||
signal: extra.signal ?? new AbortController().signal,
|
||||
...(extra.suggestions ? { suggestions: extra.suggestions } : {})
|
||||
} as unknown as Parameters<NonNullable<ClaudeStreamJsonConnectionHandlers['canUseTool']>>[2]
|
||||
let done = false
|
||||
const promise = Promise.resolve(
|
||||
connection.handlers.canUseTool?.(toolName, extra.input ?? {}, options)
|
||||
).finally(() => {
|
||||
done = true
|
||||
})
|
||||
return { promise, settled: () => done }
|
||||
}
|
||||
@@ -5,6 +5,10 @@ const MAX_CLAUDE_TRANSCRIPT_ANCESTRY = 10_000
|
||||
type TranscriptNode = {
|
||||
parentUuid: string | null
|
||||
sessionId: string | null
|
||||
/** First line where this UUID was observed in the append-only transcript. */
|
||||
lineIndex: number
|
||||
/** UUIDs from result/init/stream frames and sidechains are never leaves. */
|
||||
disallowedLeaf: boolean
|
||||
}
|
||||
|
||||
export type ClaudeTranscriptBranchProof = {
|
||||
@@ -27,6 +31,54 @@ export class ClaudeTranscriptTailIncompleteError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The sampled cursor is no longer present, so a root proof may still recover safely. */
|
||||
export class ClaudeTranscriptPreviousCursorMissingError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Claude transcript branch proof failed: previous cursor is missing from the session graph'
|
||||
)
|
||||
this.name = 'ClaudeTranscriptPreviousCursorMissingError'
|
||||
}
|
||||
}
|
||||
|
||||
function proveMainLineAncestry(
|
||||
nodes: Map<string, TranscriptNode>,
|
||||
startUuid: string,
|
||||
providerSessionId: string
|
||||
): void {
|
||||
const visited = new Set<string>()
|
||||
let cursor: string | null = startUuid
|
||||
for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
|
||||
if (visited.has(cursor)) {
|
||||
throw transcriptError('cycle in parentUuid ancestry')
|
||||
}
|
||||
visited.add(cursor)
|
||||
const node = nodes.get(cursor)
|
||||
if (!node || node.sessionId !== providerSessionId) {
|
||||
throw transcriptError(`missing ancestor ${cursor}`)
|
||||
}
|
||||
if (node.disallowedLeaf) {
|
||||
throw transcriptError(`ancestor ${cursor} is not on the main transcript`)
|
||||
}
|
||||
cursor = node.parentUuid
|
||||
}
|
||||
if (cursor !== null) {
|
||||
throw transcriptError('ancestry exceeds the bounded proof limit')
|
||||
}
|
||||
}
|
||||
|
||||
function proveAppendOrder(nodes: Map<string, TranscriptNode>): void {
|
||||
for (const node of nodes.values()) {
|
||||
if (!node.parentUuid) {
|
||||
continue
|
||||
}
|
||||
const parent = nodes.get(node.parentUuid)
|
||||
if (parent && parent.lineIndex >= node.lineIndex) {
|
||||
throw transcriptError('parent row follows descendant')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function proveClaudeTranscriptBranchFromJsonl(input: {
|
||||
contents: string
|
||||
providerSessionId: string
|
||||
@@ -34,6 +86,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
|
||||
}): ClaudeTranscriptBranchProof {
|
||||
const nodes = new Map<string, TranscriptNode>()
|
||||
let leafUuid: string | null = null
|
||||
let leafMarkerLineIndex = -1
|
||||
const lines = input.contents.split('\n')
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (!line.trim()) {
|
||||
@@ -59,6 +112,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
|
||||
throw transcriptError('invalid last-prompt marker')
|
||||
}
|
||||
leafUuid = markerLeaf
|
||||
leafMarkerLineIndex = index
|
||||
}
|
||||
const uuid = nonEmptyString(row.uuid)
|
||||
if (!uuid) {
|
||||
@@ -70,27 +124,60 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
|
||||
}
|
||||
const sessionId = nonEmptyString(row.sessionId)
|
||||
const existing = nodes.get(uuid)
|
||||
if (existing && (existing.parentUuid !== parentUuid || existing.sessionId !== sessionId)) {
|
||||
const disallowedLeaf =
|
||||
row.isSidechain === true ||
|
||||
row.parent_tool_use_id != null ||
|
||||
row.type === 'result' ||
|
||||
row.type === 'stream_event' ||
|
||||
(row.type === 'system' && row.subtype === 'init')
|
||||
if (
|
||||
existing &&
|
||||
(existing.parentUuid !== parentUuid ||
|
||||
existing.sessionId !== sessionId ||
|
||||
existing.disallowedLeaf !== disallowedLeaf)
|
||||
) {
|
||||
throw transcriptError(`record ${uuid} has conflicting ancestry`)
|
||||
}
|
||||
nodes.set(uuid, { parentUuid, sessionId })
|
||||
nodes.set(uuid, {
|
||||
parentUuid,
|
||||
sessionId,
|
||||
lineIndex: existing?.lineIndex ?? index,
|
||||
disallowedLeaf
|
||||
})
|
||||
}
|
||||
if (!leafUuid) {
|
||||
throw transcriptError('missing last-prompt marker')
|
||||
}
|
||||
const leaf = nodes.get(leafUuid)
|
||||
if (!leaf || leaf.sessionId !== input.providerSessionId) {
|
||||
if (!leaf || leaf.sessionId !== input.providerSessionId || leaf.disallowedLeaf) {
|
||||
throw transcriptError('marker leaf is missing from the session graph')
|
||||
}
|
||||
if (leaf.lineIndex > leafMarkerLineIndex) {
|
||||
throw transcriptError('marker precedes its leaf record')
|
||||
}
|
||||
const previousLeafUuid = input.previousLeafUuid
|
||||
if (!previousLeafUuid) {
|
||||
proveMainLineAncestry(nodes, leafUuid, input.providerSessionId)
|
||||
// A branch proof is based on an append-only snapshot. A child that appears
|
||||
// before its claimed parent is not a post-snapshot descendant observation;
|
||||
// accepting that graph would turn reordered/torn rows into durable ancestry.
|
||||
proveAppendOrder(nodes)
|
||||
return { leafUuid, relation: 'initial' }
|
||||
}
|
||||
const previous = nodes.get(previousLeafUuid)
|
||||
if (!previous || previous.sessionId !== input.providerSessionId) {
|
||||
throw transcriptError('previous cursor is missing from the session graph')
|
||||
if (!previous) {
|
||||
throw new ClaudeTranscriptPreviousCursorMissingError()
|
||||
}
|
||||
if (previous.sessionId !== input.providerSessionId || previous.disallowedLeaf) {
|
||||
throw transcriptError('previous cursor is not on the main transcript')
|
||||
}
|
||||
// The latest marker can be equal to, or descend from, a sampled cursor. In
|
||||
// either case prove the sampled cursor's own ancestry before accepting it;
|
||||
// otherwise a cursor that descended through a parent-tool-use sidechain
|
||||
// could be persisted and resumed as if it were on the main transcript.
|
||||
proveMainLineAncestry(nodes, previousLeafUuid, input.providerSessionId)
|
||||
if (leafUuid === previousLeafUuid) {
|
||||
proveAppendOrder(nodes)
|
||||
return { leafUuid, relation: 'same' }
|
||||
}
|
||||
const visited = new Set<string>()
|
||||
@@ -104,8 +191,12 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
|
||||
if (!node || node.sessionId !== input.providerSessionId) {
|
||||
throw transcriptError(`missing ancestor ${cursor}`)
|
||||
}
|
||||
if (node.disallowedLeaf) {
|
||||
throw transcriptError(`ancestor ${cursor} is not on the main transcript`)
|
||||
}
|
||||
cursor = node.parentUuid
|
||||
if (cursor === previousLeafUuid) {
|
||||
proveAppendOrder(nodes)
|
||||
return { leafUuid, relation: 'descendant' }
|
||||
}
|
||||
}
|
||||
@@ -126,3 +217,37 @@ export async function proveClaudeTranscriptBranch(input: {
|
||||
previousLeafUuid: input.previousLeafUuid
|
||||
})
|
||||
}
|
||||
|
||||
/** Re-run a durable branch proof from the transcript root when a sampled cursor is stale. */
|
||||
export async function readClaudeTranscriptLeafWithReproof(input: {
|
||||
readTranscriptLeaf: (input: {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
claudeConfigDir: string
|
||||
}) => Promise<string | null>
|
||||
claudeConfigDir: string
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
return await input.readTranscriptLeaf({
|
||||
providerSessionId: input.providerSessionId,
|
||||
previousLeafUuid: input.previousLeafUuid,
|
||||
claudeConfigDir: input.claudeConfigDir
|
||||
})
|
||||
} catch (error) {
|
||||
// A missing cursor can be stale after compaction and is safe to re-prove from the root. A torn
|
||||
// tail is still being written; dropping the cursor would make a later sibling look admissible.
|
||||
if (
|
||||
input.previousLeafUuid === null ||
|
||||
!(error instanceof ClaudeTranscriptPreviousCursorMissingError)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
return input.readTranscriptLeaf({
|
||||
providerSessionId: input.providerSessionId,
|
||||
previousLeafUuid: null,
|
||||
claudeConfigDir: input.claudeConfigDir
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
completeClaudeTuiExit,
|
||||
readClaudeTranscriptEntryUuid,
|
||||
readClaudeTranscriptLeafUuid
|
||||
} from './claude-tui-exit'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('Claude TUI exit', () => {
|
||||
it('does not sample UUIDs from subagent stdout frames with a parent tool use', () => {
|
||||
expect(
|
||||
readClaudeTranscriptEntryUuid({
|
||||
type: 'assistant',
|
||||
uuid: 'subagent-assistant',
|
||||
parent_tool_use_id: 'parent-tool'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
readClaudeTranscriptEntryUuid({
|
||||
type: 'assistant',
|
||||
uuid: 'main-assistant',
|
||||
parent_tool_use_id: null
|
||||
})
|
||||
).toBe('main-assistant')
|
||||
})
|
||||
|
||||
it('reads the authoritative last-prompt leaf from a transcript tail', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-exit-'))
|
||||
roots.push(root)
|
||||
const transcriptPath = join(root, 'session.jsonl')
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
[
|
||||
{ type: 'user', uuid: 'user-one' },
|
||||
{ type: 'assistant', uuid: 'assistant-one' },
|
||||
{ type: 'last-prompt', leafUuid: 'chain-head' },
|
||||
{ type: 'file-history-snapshot', snapshot: {} }
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join('\n')
|
||||
)
|
||||
|
||||
await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('chain-head')
|
||||
})
|
||||
|
||||
it('falls back to the last persisted message when last-prompt metadata is absent', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-exit-'))
|
||||
roots.push(root)
|
||||
const transcriptPath = join(root, 'session.jsonl')
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
[
|
||||
{ type: 'user', uuid: 'user-one' },
|
||||
{ type: 'assistant', uuid: 'assistant-one' },
|
||||
{ type: 'system', subtype: 'init', uuid: 'init-frame' },
|
||||
{ type: 'result', uuid: 'result-frame' },
|
||||
{ type: 'stream_event', uuid: 'stream-event-frame' }
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join('\n')
|
||||
)
|
||||
|
||||
await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('assistant-one')
|
||||
})
|
||||
|
||||
it('ignores sidechain messages when selecting a fallback transcript leaf', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-sidechain-leaf-'))
|
||||
const transcriptPath = join(root, 'session.jsonl')
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
[
|
||||
{ type: 'assistant', uuid: 'main-assistant' },
|
||||
{ type: 'assistant', uuid: 'subagent-assistant', isSidechain: true },
|
||||
{ type: 'result', uuid: 'result-frame' }
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('main-assistant')
|
||||
})
|
||||
|
||||
it('persists the resumed chain head only after the exact Claude child exits', async () => {
|
||||
let resolveExit!: (exit: {
|
||||
pid: number
|
||||
exitCode: number | null
|
||||
signal: string | null
|
||||
}) => void
|
||||
const exitPromise = new Promise<{
|
||||
pid: number
|
||||
exitCode: number | null
|
||||
signal: string | null
|
||||
}>((resolve) => {
|
||||
resolveExit = resolve
|
||||
})
|
||||
const persistHandle = vi.fn(async () => undefined)
|
||||
const completion = completeClaudeTuiExit({
|
||||
childPid: 4210,
|
||||
waitForChildExit: () => exitPromise,
|
||||
sessionId: 'provider-session',
|
||||
transcriptPath: '/accounts/claude/session.jsonl',
|
||||
fence: 7,
|
||||
persistHandle,
|
||||
readLeafUuid: async () => 'tui-leaf',
|
||||
linkId: 'tui-resumed-link',
|
||||
now: () => 12
|
||||
})
|
||||
|
||||
expect(persistHandle).not.toHaveBeenCalled()
|
||||
resolveExit({ pid: 4210, exitCode: 0, signal: null })
|
||||
|
||||
await expect(completion).resolves.toMatchObject({
|
||||
link: {
|
||||
linkId: 'tui-resumed-link',
|
||||
handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'tui-leaf' },
|
||||
origin: 'resumed',
|
||||
mintedAtFence: 7,
|
||||
observedAt: 12
|
||||
}
|
||||
})
|
||||
expect(persistHandle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses another process exit and a missing transcript leaf', async () => {
|
||||
const persistHandle = vi.fn(async () => undefined)
|
||||
await expect(
|
||||
completeClaudeTuiExit({
|
||||
childPid: 4210,
|
||||
waitForChildExit: async () => ({ pid: 4211, exitCode: 0, signal: null }),
|
||||
sessionId: 'provider-session',
|
||||
transcriptPath: '/session.jsonl',
|
||||
fence: 2,
|
||||
persistHandle,
|
||||
readLeafUuid: async () => 'leaf'
|
||||
})
|
||||
).rejects.toThrow(/did not belong to the Claude child/)
|
||||
await expect(
|
||||
completeClaudeTuiExit({
|
||||
childPid: 4210,
|
||||
waitForChildExit: async () => ({ pid: 4210, exitCode: 1, signal: null }),
|
||||
sessionId: 'provider-session',
|
||||
transcriptPath: '/session.jsonl',
|
||||
fence: 2,
|
||||
persistHandle,
|
||||
readLeafUuid: async () => null
|
||||
})
|
||||
).rejects.toThrow(/resumable transcript leaf/)
|
||||
expect(persistHandle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { open } from 'node:fs/promises'
|
||||
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
|
||||
import { claudeProviderHandleLink } from './claude-structured-owner-identity'
|
||||
|
||||
const TRANSCRIPT_TAIL_CHUNK_BYTES = 64 * 1024
|
||||
const TRANSCRIPT_TAIL_READ_LIMIT_BYTES = 4 * 1024 * 1024
|
||||
|
||||
type TranscriptLeafCandidate = { leafUuid: string; authoritative: boolean }
|
||||
|
||||
function validLeafUuid(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > 512) {
|
||||
return null
|
||||
}
|
||||
const hasControlCharacter = [...value].some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0
|
||||
return code <= 0x1f || code === 0x7f
|
||||
})
|
||||
return value === value.trim() && !hasControlCharacter ? value : null
|
||||
}
|
||||
|
||||
export function readClaudeTranscriptEntryUuid(value: Record<string, unknown>): string | null {
|
||||
return value.isSidechain === true ||
|
||||
value.parent_tool_use_id != null ||
|
||||
(value.type !== 'user' && value.type !== 'assistant')
|
||||
? null
|
||||
: validLeafUuid(value.uuid)
|
||||
}
|
||||
|
||||
function readLeafCandidate(line: string): TranscriptLeafCandidate | null {
|
||||
try {
|
||||
const value = JSON.parse(line) as Record<string, unknown>
|
||||
const lastPromptLeaf = value.type === 'last-prompt' ? validLeafUuid(value.leafUuid) : null
|
||||
if (lastPromptLeaf) {
|
||||
return { leafUuid: lastPromptLeaf, authoritative: true }
|
||||
}
|
||||
const messageLeaf = readClaudeTranscriptEntryUuid(value)
|
||||
return messageLeaf ? { leafUuid: messageLeaf, authoritative: false } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function readClaudeTranscriptLeafUuid(transcriptPath: string): Promise<string | null> {
|
||||
const file = await open(transcriptPath, 'r')
|
||||
try {
|
||||
const { size } = await file.stat()
|
||||
let position = size
|
||||
let suffix = ''
|
||||
let fallback: string | null = null
|
||||
let scanned = 0
|
||||
while (position > 0 && scanned < TRANSCRIPT_TAIL_READ_LIMIT_BYTES) {
|
||||
const length = Math.min(TRANSCRIPT_TAIL_CHUNK_BYTES, position)
|
||||
position -= length
|
||||
scanned += length
|
||||
const buffer = Buffer.alloc(length)
|
||||
await file.read(buffer, 0, length, position)
|
||||
const lines = `${buffer.toString('utf8')}${suffix}`.split(/\r?\n/)
|
||||
suffix = position > 0 ? (lines.shift() ?? '') : ''
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index]?.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const candidate = readLeafCandidate(line)
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
if (candidate.authoritative) {
|
||||
return candidate.leafUuid
|
||||
}
|
||||
fallback ??= candidate.leafUuid
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudeTuiChildExit = {
|
||||
pid: number
|
||||
exitCode: number | null
|
||||
signal: string | null
|
||||
}
|
||||
|
||||
export async function completeClaudeTuiExit(input: {
|
||||
childPid: number
|
||||
waitForChildExit: () => Promise<ClaudeTuiChildExit>
|
||||
sessionId: string
|
||||
transcriptPath: string
|
||||
fence: number
|
||||
persistHandle: (link: AgentSessionProviderHandleLink) => Promise<void>
|
||||
readLeafUuid?: (transcriptPath: string) => Promise<string | null>
|
||||
linkId?: string
|
||||
now?: () => number
|
||||
}): Promise<{
|
||||
exit: ClaudeTuiChildExit
|
||||
transcriptPath: string
|
||||
link: AgentSessionProviderHandleLink
|
||||
}> {
|
||||
const exit = await input.waitForChildExit()
|
||||
if (exit.pid !== input.childPid) {
|
||||
throw new Error('The observed process exit did not belong to the Claude child.')
|
||||
}
|
||||
const leafUuid = await (input.readLeafUuid ?? readClaudeTranscriptLeafUuid)(input.transcriptPath)
|
||||
if (!leafUuid) {
|
||||
throw new Error('The exited Claude TUI did not persist a resumable transcript leaf.')
|
||||
}
|
||||
const link = claudeProviderHandleLink({
|
||||
sessionId: input.sessionId,
|
||||
leafUuid,
|
||||
resumed: true,
|
||||
fence: input.fence,
|
||||
...(input.linkId ? { linkId: input.linkId } : {}),
|
||||
observedAt: input.now?.() ?? Date.now()
|
||||
})
|
||||
await input.persistHandle(link)
|
||||
return { exit, transcriptPath: input.transcriptPath, link }
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE } from '../claude-accounts/environment'
|
||||
import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution'
|
||||
import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity'
|
||||
import { createClaudeTuiResumeLaunchBuilder } from './claude-tui-resume-launch'
|
||||
|
||||
function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord {
|
||||
return {
|
||||
sessionId: 'orca-session-1',
|
||||
provider: 'claude',
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-folder',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude-one' },
|
||||
providerHandleChain: [
|
||||
{
|
||||
linkId: 'created',
|
||||
handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'leaf-one' },
|
||||
origin: 'created',
|
||||
mintedAtFence: 1,
|
||||
observedAt: 1
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
} as AgentSessionRecord
|
||||
}
|
||||
|
||||
function makeExecutable(path: string): void {
|
||||
mkdirSync(join(path, '..'), { recursive: true })
|
||||
writeFileSync(path, '')
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude TUI resume launch', () => {
|
||||
it('pins the workspace, account home, setting sources, and launch identity', async () => {
|
||||
const build = createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async (workspaceId) => `/workspaces/${workspaceId}`,
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: false }),
|
||||
resolveEnv: () => ({
|
||||
SELECTED_ACCOUNT: 'one',
|
||||
ANTHROPIC_AUTH_TOKEN: 'selected-account-token'
|
||||
}),
|
||||
inheritedEnv: {
|
||||
ANTHROPIC_API_KEY: 'inherited-gateway-key',
|
||||
ANTHROPIC_BASE_URL: 'https://inherited-gateway.invalid',
|
||||
CLAUDE_CODE_SESSION_ID: 'parent-session',
|
||||
SAFE_PARENT: 'kept'
|
||||
}
|
||||
})
|
||||
|
||||
const launch = await build({ record: record(), spawnToken: 'spawn-one' })
|
||||
|
||||
expect(launch).toMatchObject({
|
||||
command: '/usr/local/bin/claude',
|
||||
args: [
|
||||
'--setting-sources',
|
||||
CLAUDE_DEFAULT_SETTING_SOURCES.join(','),
|
||||
'--resume',
|
||||
'provider-session'
|
||||
],
|
||||
cwd: '/workspaces/workspace-folder',
|
||||
providerSessionId: 'provider-session',
|
||||
resumeLeafUuid: 'leaf-one'
|
||||
})
|
||||
expect(launch.env).toMatchObject({
|
||||
SAFE_PARENT: 'kept',
|
||||
SELECTED_ACCOUNT: 'one',
|
||||
CLAUDE_CONFIG_DIR: '/accounts/claude-one',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'spawn-one',
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-one',
|
||||
ANTHROPIC_AUTH_TOKEN: 'selected-account-token'
|
||||
})
|
||||
// System auth (the only state an explicit ANTHROPIC_AUTH_TOKEN overlay is legal in):
|
||||
// the user's own inherited key is their sign-in and survives. The managed-account
|
||||
// half — where it is stripped — is covered by 'structured-to-TUI handoff auth'.
|
||||
expect(launch.env.ANTHROPIC_API_KEY).toBe('inherited-gateway-key')
|
||||
// Endpoint selection is not credential material; the existing adapter pinning preserves it.
|
||||
expect(launch.env.ANTHROPIC_BASE_URL).toBe('https://inherited-gateway.invalid')
|
||||
expect(launch.env.CLAUDE_CODE_SESSION_ID).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pairs the resumed Claude CLI with its sibling Node runtime', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-claude-resume-'))
|
||||
const binDir = join(root, 'bin')
|
||||
const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude')
|
||||
const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')
|
||||
makeExecutable(claudeCommand)
|
||||
makeExecutable(nodeCommand)
|
||||
|
||||
const build = createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async () => '/workspace',
|
||||
resolveCommand: () => claudeCommand,
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
resolveEnv: () => ({ PATH: '/usr/bin' }),
|
||||
inheritedEnv: {}
|
||||
})
|
||||
|
||||
const launch = await build({ record: record(), spawnToken: 'spawn' })
|
||||
|
||||
expect((launch.env.PATH ?? launch.env.Path)?.split(delimiter)[0]).toBe(binDir)
|
||||
})
|
||||
|
||||
it('uses the durable session environment instead of current account settings', async () => {
|
||||
const build = createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async () => '/workspace',
|
||||
resolveCommand: () => 'claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: false }),
|
||||
resolveEnv: () => ({ ANTHROPIC_AUTH_TOKEN: 'pinned-token' }),
|
||||
inheritedEnv: {}
|
||||
})
|
||||
|
||||
const launch = await build({ record: record(), spawnToken: 'spawn' })
|
||||
|
||||
expect(launch.env.ANTHROPIC_AUTH_TOKEN).toBe('pinned-token')
|
||||
})
|
||||
|
||||
it('resolves the durable chain head instead of an earlier Claude leaf', async () => {
|
||||
const nextRecord = record({
|
||||
providerHandleChain: [
|
||||
...record().providerHandleChain,
|
||||
{
|
||||
linkId: 'resumed',
|
||||
handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'leaf-two' },
|
||||
origin: 'resumed',
|
||||
mintedAtFence: 2,
|
||||
observedAt: 2
|
||||
}
|
||||
]
|
||||
})
|
||||
const build = createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async () => '/workspace',
|
||||
resolveCommand: () => 'claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
inheritedEnv: {}
|
||||
})
|
||||
|
||||
await expect(build({ record: nextRecord, spawnToken: 'spawn-two' })).resolves.toMatchObject({
|
||||
providerSessionId: 'provider-session',
|
||||
resumeLeafUuid: 'leaf-two'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves durable Claude launch arguments before resume defaults', async () => {
|
||||
const build = createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async () => '/workspace',
|
||||
resolveCommand: () => 'claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
inheritedEnv: {}
|
||||
})
|
||||
|
||||
const launch = await build({
|
||||
record: record({ launchArgs: ['--model', 'claude-sonnet-4-5'] }),
|
||||
spawnToken: 'spawn'
|
||||
})
|
||||
|
||||
expect(launch.args.slice(0, 3)).toEqual(['--model', 'claude-sonnet-4-5', '--setting-sources'])
|
||||
})
|
||||
|
||||
it('rejects missing Claude handles and unpinned account homes', async () => {
|
||||
const build = createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async () => '/workspace',
|
||||
resolveCommand: () => 'claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
inheritedEnv: {}
|
||||
})
|
||||
|
||||
await expect(
|
||||
build({ record: record({ providerHandleChain: [] }), spawnToken: 'spawn' })
|
||||
).rejects.toThrow('claude_tui_resume_handle_required')
|
||||
await expect(
|
||||
build({
|
||||
record: record({ accountHome: { variable: 'CODEX_HOME', path: '/wrong' } }),
|
||||
spawnToken: 'spawn'
|
||||
})
|
||||
).rejects.toThrow(/CLAUDE_CONFIG_DIR/)
|
||||
})
|
||||
})
|
||||
|
||||
// buildClaudeChildProcessEnv strips its inherited half unconditionally, so this module
|
||||
// would have signed a system-auth user out of the session the structured path had just
|
||||
// honoured. It is not wired up yet; the required policy is what stops the next caller
|
||||
// from inheriting that.
|
||||
describe('structured-to-TUI handoff auth', () => {
|
||||
it('carries a system-auth user their own inherited credential', async () => {
|
||||
const launch = await createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async () => '/repos/workspace-1',
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: false }),
|
||||
inheritedEnv: { ANTHROPIC_API_KEY: 'sk-ant-SHELL', PATH: '/usr/bin' }
|
||||
})({ record: record(), spawnToken: 'token-1' })
|
||||
|
||||
expect(launch.env.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL')
|
||||
})
|
||||
|
||||
it('still strips it once a managed account owns the credential', async () => {
|
||||
const launch = await createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async () => '/repos/workspace-1',
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
inheritedEnv: { ANTHROPIC_API_KEY: 'sk-ant-SHELL', PATH: '/usr/bin' }
|
||||
})({ record: record(), spawnToken: 'token-1' })
|
||||
|
||||
expect(launch.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a configured override of a pinned managed account, as the terminal path does', async () => {
|
||||
await expect(
|
||||
createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async () => '/repos/workspace-1',
|
||||
resolveCommand: () => '/usr/local/bin/claude',
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
resolveEnv: () => ({ ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' }),
|
||||
inheritedEnv: {}
|
||||
})({ record: record(), spawnToken: 'token-1' })
|
||||
).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
|
||||
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
||||
import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
|
||||
claudeAuthEnvCarriedForward,
|
||||
hasClaudeAuthEnvConflict
|
||||
} from '../claude-accounts/environment'
|
||||
import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
|
||||
import { claudeConfigDirEnvPatch } from './claude-config-dir-pin'
|
||||
import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution'
|
||||
import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity'
|
||||
|
||||
export const CLAUDE_TUI_RESUME_BASE_ARGS = [
|
||||
'--setting-sources',
|
||||
CLAUDE_DEFAULT_SETTING_SOURCES.join(',')
|
||||
] as const
|
||||
|
||||
export type ClaudeTuiResumeLaunch = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
env: Record<string, string>
|
||||
providerSessionId: string
|
||||
resumeLeafUuid: string | null
|
||||
}
|
||||
|
||||
export type ClaudeTuiResumeLaunchBuilderDeps = {
|
||||
resolveWorkspacePath: (workspaceId: string) => Promise<string>
|
||||
resolveCommand?: () => string
|
||||
resolveEnv?: () => Record<string, string>
|
||||
inheritedEnv?: NodeJS.ProcessEnv
|
||||
/**
|
||||
* Required so whoever wires this module up has to answer the question rather than
|
||||
* inherit the wrong default: buildClaudeChildProcessEnv strips its inherited half
|
||||
* unconditionally, which would sign out a system-auth user whose own ANTHROPIC_*
|
||||
* is their only credential. Build it with claudeStructuredAuthPolicyForSettings.
|
||||
*/
|
||||
resolveAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
|
||||
}
|
||||
|
||||
export function createClaudeTuiResumeLaunchBuilder(
|
||||
deps: ClaudeTuiResumeLaunchBuilderDeps
|
||||
): (input: { record: AgentSessionRecord; spawnToken: string }) => Promise<ClaudeTuiResumeLaunch> {
|
||||
return async ({ record, spawnToken }) => {
|
||||
if (record.provider !== 'claude') {
|
||||
throw new Error(`session ${record.sessionId} is a ${record.provider} session`)
|
||||
}
|
||||
if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') {
|
||||
throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`)
|
||||
}
|
||||
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
|
||||
if (head?.handle.provider !== 'claude') {
|
||||
throw new Error('claude_tui_resume_handle_required')
|
||||
}
|
||||
|
||||
const command = (deps.resolveCommand ?? resolveClaudeCommand)()
|
||||
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [
|
||||
...(record.launchArgs ?? []),
|
||||
...CLAUDE_TUI_RESUME_BASE_ARGS,
|
||||
'--resume',
|
||||
head.handle.sessionId
|
||||
])
|
||||
const auth = await deps.resolveAuthPolicy()
|
||||
const configuredEnv = deps.resolveEnv?.() ?? {}
|
||||
if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(configuredEnv)) {
|
||||
throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
}
|
||||
// The inherited half is always stripped downstream, so a system-auth user's own
|
||||
// credential only reaches the resumed TUI if it is carried in the configured half.
|
||||
const carriedAuth = auth.stripAuthEnv
|
||||
? {}
|
||||
: claudeAuthEnvCarriedForward(deps.inheritedEnv ?? process.env)
|
||||
// Compared against what the child would otherwise inherit, so the record's account
|
||||
// home still wins over a diverging overlay without a needless pin.
|
||||
const inheritedEnv = { ...(deps.inheritedEnv ?? process.env), ...configuredEnv }
|
||||
const env = buildClaudeChildProcessEnv(
|
||||
{
|
||||
...carriedAuth,
|
||||
...configuredEnv,
|
||||
...claudeConfigDirEnvPatch(record.accountHome.path, { env: inheritedEnv }),
|
||||
ORCA_AGENT_LAUNCH_TOKEN: spawnToken,
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: spawnToken
|
||||
},
|
||||
{ inheritedEnv: deps.inheritedEnv }
|
||||
)
|
||||
const pairedEnv = withCliRuntimeOnPath(command, env, { platform: process.platform })
|
||||
|
||||
return {
|
||||
command: spawnCmd,
|
||||
args: spawnArgs,
|
||||
cwd: await deps.resolveWorkspacePath(record.location.workspaceId),
|
||||
env: pairedEnv,
|
||||
providerSessionId: head.handle.sessionId,
|
||||
resumeLeafUuid: head.handle.leafUuid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { proveClaudeTuiResume, readClaudeTuiSessionStartEvidence } from './claude-tui-resume-proof'
|
||||
|
||||
const SESSION = '91deba8d-a398-4b69-a05d-35041536fe8e'
|
||||
const TRANSCRIPT = '/accounts/claude/projects/workspace/transcript.jsonl'
|
||||
|
||||
function envelope(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
launchToken: 'spawn-one',
|
||||
payload: JSON.stringify({
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'resume',
|
||||
session_id: SESSION,
|
||||
transcript_path: TRANSCRIPT,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude TUI resume proof', () => {
|
||||
it('reads SessionStart identity from the hook envelope', () => {
|
||||
expect(readClaudeTuiSessionStartEvidence(envelope())).toEqual({
|
||||
hookEventName: 'SessionStart',
|
||||
source: 'resume',
|
||||
sessionId: SESSION,
|
||||
transcriptPath: TRANSCRIPT,
|
||||
launchToken: 'spawn-one'
|
||||
})
|
||||
})
|
||||
|
||||
it('proves the exact launched session and transcript without terminal output', async () => {
|
||||
await expect(
|
||||
proveClaudeTuiResume({
|
||||
expectedSessionId: SESSION,
|
||||
expectedTranscriptPath: TRANSCRIPT,
|
||||
expectedLaunchToken: 'spawn-one',
|
||||
waitForSessionStart: async () => envelope()
|
||||
})
|
||||
).resolves.toMatchObject({ sessionId: SESSION, transcriptPath: TRANSCRIPT })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['source', { source: 'startup' }, /resume SessionStart/],
|
||||
['session', { session_id: 'other-session' }, /different Claude session/],
|
||||
['transcript', { transcript_path: '/other/transcript.jsonl' }, /different Claude transcript/]
|
||||
])('rejects a mismatched %s', async (_name, overrides, expected) => {
|
||||
await expect(
|
||||
proveClaudeTuiResume({
|
||||
expectedSessionId: SESSION,
|
||||
expectedTranscriptPath: TRANSCRIPT,
|
||||
expectedLaunchToken: 'spawn-one',
|
||||
waitForSessionStart: async () => envelope(overrides)
|
||||
})
|
||||
).rejects.toThrow(expected)
|
||||
})
|
||||
|
||||
it('rejects a SessionStart from another launched process', async () => {
|
||||
await expect(
|
||||
proveClaudeTuiResume({
|
||||
expectedSessionId: SESSION,
|
||||
expectedTranscriptPath: TRANSCRIPT,
|
||||
expectedLaunchToken: 'spawn-two',
|
||||
waitForSessionStart: async () => envelope()
|
||||
})
|
||||
).rejects.toThrow(/different launched process/)
|
||||
})
|
||||
|
||||
it('compares Windows paths using host path semantics', async () => {
|
||||
await expect(
|
||||
proveClaudeTuiResume({
|
||||
expectedSessionId: SESSION,
|
||||
expectedTranscriptPath: 'C:\\Users\\Dev\\session.jsonl',
|
||||
expectedLaunchToken: 'spawn-one',
|
||||
platform: 'win32',
|
||||
waitForSessionStart: async () =>
|
||||
envelope({ transcript_path: 'c:\\users\\dev\\session.jsonl' })
|
||||
})
|
||||
).resolves.toMatchObject({ sessionId: SESSION })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { posix, win32 } from 'node:path'
|
||||
|
||||
export type ClaudeTuiSessionStartEvidence = {
|
||||
hookEventName: 'SessionStart'
|
||||
source: 'resume'
|
||||
sessionId: string
|
||||
transcriptPath: string
|
||||
launchToken: string
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function hookPayload(envelope: Record<string, unknown>): Record<string, unknown> | null {
|
||||
if (typeof envelope.payload === 'string') {
|
||||
try {
|
||||
return record(JSON.parse(envelope.payload))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return record(envelope.payload) ?? envelope
|
||||
}
|
||||
|
||||
export function readClaudeTuiSessionStartEvidence(
|
||||
value: unknown
|
||||
): ClaudeTuiSessionStartEvidence | null {
|
||||
const envelope = record(value)
|
||||
if (!envelope) {
|
||||
return null
|
||||
}
|
||||
const payload = hookPayload(envelope)
|
||||
if (!payload) {
|
||||
return null
|
||||
}
|
||||
const hookEventName = nonEmptyString(payload.hook_event_name ?? payload.hookEventName)
|
||||
const source = nonEmptyString(payload.source)
|
||||
const sessionId = nonEmptyString(payload.session_id ?? payload.sessionId)
|
||||
const transcriptPath = nonEmptyString(payload.transcript_path ?? payload.transcriptPath)
|
||||
const launchToken = nonEmptyString(envelope.launchToken ?? payload.launchToken)
|
||||
return hookEventName === 'SessionStart' &&
|
||||
source === 'resume' &&
|
||||
sessionId &&
|
||||
transcriptPath &&
|
||||
launchToken
|
||||
? { hookEventName, source, sessionId, transcriptPath, launchToken }
|
||||
: null
|
||||
}
|
||||
|
||||
function comparablePath(value: string, platform: NodeJS.Platform): string | null {
|
||||
if (value.includes('\0')) {
|
||||
return null
|
||||
}
|
||||
const path = platform === 'win32' ? win32 : posix
|
||||
if (!path.isAbsolute(value)) {
|
||||
return null
|
||||
}
|
||||
const normalized = path.normalize(value)
|
||||
return platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
export async function proveClaudeTuiResume(input: {
|
||||
expectedSessionId: string
|
||||
expectedTranscriptPath: string
|
||||
expectedLaunchToken: string
|
||||
waitForSessionStart: () => Promise<unknown>
|
||||
timeoutMs?: number
|
||||
platform?: NodeJS.Platform
|
||||
}): Promise<ClaudeTuiSessionStartEvidence> {
|
||||
const timeoutMs = input.timeoutMs ?? 15_000
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const evidence = readClaudeTuiSessionStartEvidence(
|
||||
await Promise.race([
|
||||
input.waitForSessionStart(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error('The agent terminal did not prove the expected Claude resume.')),
|
||||
timeoutMs
|
||||
)
|
||||
timer.unref?.()
|
||||
})
|
||||
])
|
||||
)
|
||||
if (!evidence) {
|
||||
throw new Error('The agent terminal did not emit a Claude resume SessionStart proof.')
|
||||
}
|
||||
if (evidence.launchToken !== input.expectedLaunchToken) {
|
||||
throw new Error('The Claude resume proof came from a different launched process.')
|
||||
}
|
||||
if (evidence.sessionId !== input.expectedSessionId) {
|
||||
throw new Error('The agent terminal resumed a different Claude session.')
|
||||
}
|
||||
const platform = input.platform ?? process.platform
|
||||
const expectedPath = comparablePath(input.expectedTranscriptPath, platform)
|
||||
const observedPath = comparablePath(evidence.transcriptPath, platform)
|
||||
if (!expectedPath || !observedPath || observedPath !== expectedPath) {
|
||||
throw new Error('The agent terminal resumed a different Claude transcript.')
|
||||
}
|
||||
return evidence
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as pty from 'node-pty'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity'
|
||||
import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
|
||||
import {
|
||||
ClaudeStructuredSessionAdapter,
|
||||
type ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-adapter'
|
||||
import { createClaudeTuiResumeLaunchBuilder } from './claude-tui-resume-launch'
|
||||
import { proveClaudeTuiResume } from './claude-tui-resume-proof'
|
||||
|
||||
const command = resolveClaudeCommand()
|
||||
const claudeAvailable =
|
||||
spawnSync(command, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0
|
||||
const authStatusLaunch = getSpawnArgsForWindows(command, ['auth', 'status', '--json'])
|
||||
const claudeAuthenticated = (() => {
|
||||
if (!claudeAvailable) {
|
||||
return false
|
||||
}
|
||||
const result = spawnSync(authStatusLaunch.spawnCmd, authStatusLaunch.spawnArgs, {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 5_000
|
||||
})
|
||||
return result.status === 0 && /"loggedIn"\s*:\s*true/.test(result.stdout)
|
||||
})()
|
||||
const roots: string[] = []
|
||||
const transcripts: string[] = []
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return process.platform === 'win32'
|
||||
? `"${value.replace(/"/g, '""')}"`
|
||||
: `'${value.replace(/'/g, `'"'"'`)}'`
|
||||
}
|
||||
|
||||
async function installCaptureHook(
|
||||
root: string
|
||||
): Promise<{ eventsPath: string; settingsPath: string }> {
|
||||
const scriptPath = join(root, 'capture-session-start.cjs')
|
||||
const eventsPath = join(root, 'session-start.jsonl')
|
||||
const settingsPath = join(root, 'settings.json')
|
||||
await writeFile(
|
||||
scriptPath,
|
||||
[
|
||||
"const { appendFileSync } = require('node:fs')",
|
||||
"let input = ''",
|
||||
"process.stdin.setEncoding('utf8')",
|
||||
"process.stdin.on('data', (chunk) => { input += chunk })",
|
||||
"process.stdin.on('end', () => {",
|
||||
' const payload = JSON.parse(input)',
|
||||
' payload.launchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN',
|
||||
' appendFileSync(process.argv[2], `${JSON.stringify(payload)}\\n`)',
|
||||
'})',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
theme: 'dark',
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: [process.execPath, scriptPath, eventsPath].map(shellQuote).join(' ')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
return { eventsPath, settingsPath }
|
||||
}
|
||||
|
||||
async function waitForHook(
|
||||
eventsPath: string,
|
||||
source: 'startup' | 'resume'
|
||||
): Promise<Record<string, unknown>> {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
const contents = await readFile(eventsPath, 'utf8').catch(() => '')
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
const event = JSON.parse(line) as Record<string, unknown>
|
||||
if (event.hook_event_name === 'SessionStart' && event.source === source) {
|
||||
return event
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
throw new Error(`Claude did not emit a ${source} SessionStart hook`)
|
||||
}
|
||||
|
||||
type RunningTui = { proc: pty.IPty; exited: Promise<void> }
|
||||
|
||||
function spawnResumeTui(args: string[], env: Record<string, string>): RunningTui {
|
||||
const direct = process.platform === 'win32'
|
||||
const proc = pty.spawn(
|
||||
direct ? command : process.env.SHELL || '/bin/zsh',
|
||||
direct ? args : ['-l'],
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: process.cwd(),
|
||||
env: { ...env, TERM: 'xterm-256color' }
|
||||
}
|
||||
)
|
||||
if (!direct) {
|
||||
setTimeout(() => {
|
||||
proc.write(`${[command, ...args].map(shellQuote).join(' ')}\r`)
|
||||
}, 100).unref()
|
||||
}
|
||||
return { proc, exited: new Promise<void>((resolve) => proc.onExit(() => resolve())) }
|
||||
}
|
||||
|
||||
function structuredIdentity(providerSessionId: string): AgentSessionJournalIdentity {
|
||||
return {
|
||||
sessionId: 'orca-real-claude-resume',
|
||||
workspaceId: 'workspace-real',
|
||||
hostId: 'local',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForStructuredResult(events: ClaudeStructuredSessionEvent[]): Promise<void> {
|
||||
const deadline = Date.now() + 30_000
|
||||
while (Date.now() < deadline) {
|
||||
if (events.some((event) => event.type === 'message' && event.message.type === 'result')) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
throw new Error('Claude structured session did not finish its product-path turn')
|
||||
}
|
||||
|
||||
async function stopTui(tui: RunningTui): Promise<void> {
|
||||
try {
|
||||
tui.proc.kill('SIGKILL')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Promise.race([
|
||||
tui.exited,
|
||||
new Promise<never>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error('Claude TUI did not exit after cleanup')), 5_000)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(transcripts.splice(0).map((path) => rm(path, { force: true })))
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => {
|
||||
it('resumes a product-created structured session and proves its exact child', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-resume-'))
|
||||
roots.push(root)
|
||||
const { eventsPath, settingsPath } = await installCaptureHook(root)
|
||||
const providerSessionId = randomUUID()
|
||||
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
pathToClaudeCodeExecutable: command,
|
||||
options: {
|
||||
...CLAUDE_STRUCTURED_BASE_OPTIONS,
|
||||
extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: settingsPath },
|
||||
sessionId: providerSessionId
|
||||
},
|
||||
cwd: process.cwd(),
|
||||
claudeConfigDir,
|
||||
providerSessionId,
|
||||
resumeLeafUuid: null,
|
||||
resumed: false
|
||||
}),
|
||||
onEvent: (event) => events.push(event),
|
||||
readProcessStartTime: async () => 1
|
||||
})
|
||||
let resumed: RunningTui | null = null
|
||||
try {
|
||||
const acquisition = await adapter.acquire({
|
||||
identity: structuredIdentity(providerSessionId),
|
||||
fence: 1,
|
||||
spawnToken: 'real-create'
|
||||
})
|
||||
await expect(
|
||||
adapter.dispatch({
|
||||
sessionId: 'orca-real-claude-resume',
|
||||
clientMessageId: 'real-product-turn',
|
||||
fence: 1,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'Reply only with ORCA_RESUME_READY.' }]
|
||||
}
|
||||
})
|
||||
).resolves.toMatchObject({ state: 'accepted' })
|
||||
await waitForStructuredResult(events)
|
||||
const started = await waitForHook(eventsPath, 'startup')
|
||||
const transcriptPath = String(started.transcript_path)
|
||||
transcripts.push(transcriptPath)
|
||||
expect(started.session_id).toBe(providerSessionId)
|
||||
await adapter.closeAll()
|
||||
|
||||
const record = {
|
||||
sessionId: 'orca-real-claude-resume',
|
||||
provider: 'claude',
|
||||
location: { workspaceId: 'workspace-real' },
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: claudeConfigDir },
|
||||
providerHandleChain: [
|
||||
{
|
||||
linkId: 'created-real',
|
||||
handle: acquisition.link.handle,
|
||||
origin: 'created',
|
||||
mintedAtFence: 1,
|
||||
observedAt: 1
|
||||
}
|
||||
]
|
||||
} as AgentSessionRecord
|
||||
const launch = await createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async () => process.cwd(),
|
||||
resolveCommand: () => command,
|
||||
// The real binary authenticates from the developer's own environment here,
|
||||
// which is the system-auth case: stripping it would sign the resume out.
|
||||
resolveAuthPolicy: () => ({ stripAuthEnv: false })
|
||||
})({ record, spawnToken: 'real-resume' })
|
||||
resumed = spawnResumeTui([...launch.args, '--settings', settingsPath], launch.env)
|
||||
let resumedOutput = ''
|
||||
resumed.proc.onData((data) => {
|
||||
resumedOutput = `${resumedOutput}${data}`.slice(-4_000)
|
||||
})
|
||||
|
||||
const [processIdentity, proof] = await Promise.all([
|
||||
readStructuredTuiProcessIdentity({
|
||||
hostId: 'local',
|
||||
rootPid: resumed.proc.pid,
|
||||
spawnToken: 'real-resume',
|
||||
agent: 'claude'
|
||||
}),
|
||||
proveClaudeTuiResume({
|
||||
expectedSessionId: providerSessionId,
|
||||
expectedTranscriptPath: transcriptPath,
|
||||
expectedLaunchToken: 'real-resume',
|
||||
waitForSessionStart: () => waitForHook(eventsPath, 'resume')
|
||||
}).catch((error) => {
|
||||
throw new Error(`${String(error)}\nClaude output: ${resumedOutput}`)
|
||||
})
|
||||
])
|
||||
expect(processIdentity).toMatchObject({
|
||||
hostId: 'local',
|
||||
spawnToken: 'real-resume',
|
||||
pid: expect.any(Number)
|
||||
})
|
||||
expect(proof).toMatchObject({ sessionId: providerSessionId, transcriptPath })
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
if (resumed) {
|
||||
await stopTui(resumed)
|
||||
}
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from './codex-structured-session-adapter'
|
||||
import { handleCodexSessionExit } from './codex-structured-session-close'
|
||||
import type { CodexSession } from './codex-structured-session-state'
|
||||
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
|
||||
|
||||
const THREAD = 'thread-1'
|
||||
|
||||
@@ -60,6 +62,16 @@ function adapterFixture() {
|
||||
return { adapter, connections, events }
|
||||
}
|
||||
|
||||
function claudeAdapterStub(): StructuredAgentSessionAdapter {
|
||||
return {
|
||||
acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never),
|
||||
dispatch: vi.fn(),
|
||||
cancelTurn: vi.fn(),
|
||||
answerPrompt: vi.fn(),
|
||||
setOption: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('Codex structured session close lifecycle', () => {
|
||||
it('forwards a one-shot exit when lifecycle admission is rejected', () => {
|
||||
const connection: CodexAppServerConnection = {
|
||||
@@ -164,4 +176,27 @@ describe('Codex structured session close lifecycle', () => {
|
||||
{ cause: 'unexpected-exit', reason: 'sink failed', fence: 7 }
|
||||
])
|
||||
})
|
||||
|
||||
it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => {
|
||||
const { adapter, connections, events } = adapterFixture()
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
{ claude: claudeAdapterStub(), codex: adapter },
|
||||
async () => {}
|
||||
)
|
||||
await router.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' })
|
||||
const current = connections[0]
|
||||
if (!current) {
|
||||
throw new Error('missing connection')
|
||||
}
|
||||
current.connection.close = async () => {
|
||||
current.handlers.onExit?.(new Error('journal sink failed'))
|
||||
return true
|
||||
}
|
||||
|
||||
const forceCloseSession = router.forceCloseSession
|
||||
await expect(forceCloseSession('session-1')).resolves.toBe(true)
|
||||
expect(events.filter((event) => event.type === 'ended')).toMatchObject([
|
||||
{ cause: 'unexpected-exit', reason: 'journal sink failed', fence: 7 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,11 @@ import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay'
|
||||
import { isOpaqueRemintedPaneKey } from '../../../../shared/pane-key-alias'
|
||||
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
|
||||
import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate'
|
||||
import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
|
||||
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE,
|
||||
hasClaudeAuthEnvConflict
|
||||
} from '../../../claude-accounts/environment'
|
||||
import { LocalPtyProvider } from '../../../providers/local-pty-provider'
|
||||
import { resolvePathEnvKey } from '../../../pty/windows-environment-path'
|
||||
import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing'
|
||||
@@ -20,12 +24,10 @@ import { assemblePtyIpcSpawnCodexEnv } from './spawn-env-codex'
|
||||
export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise<void> {
|
||||
const args = ctx.args
|
||||
if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||||
throw new Error(
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
)
|
||||
throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
}
|
||||
// Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity.
|
||||
// Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '../../../../shared/local-windows-terminal-runtime'
|
||||
import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths'
|
||||
import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate'
|
||||
import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment'
|
||||
import { mintPtySessionId } from '../../../daemon/pty-session-id'
|
||||
import { resolveWslSessionContext } from '../../../daemon/wsl-session-context'
|
||||
import { LocalPtyProvider } from '../../../providers/local-pty-provider'
|
||||
@@ -193,7 +194,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis
|
||||
ctx.isClaudeLaunch =
|
||||
!ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command)
|
||||
if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
ctx.terminalRuntimeOptions =
|
||||
process.platform === 'win32' && !args.connectionId
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate'
|
||||
import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment'
|
||||
import {
|
||||
CLAUDE_AUTH_ENV_CONFLICT_MESSAGE,
|
||||
CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE,
|
||||
hasClaudeAuthEnvConflict
|
||||
} from '../../../claude-accounts/environment'
|
||||
import {
|
||||
isSafePtySessionId,
|
||||
mintPtySessionId,
|
||||
@@ -65,7 +69,7 @@ export async function prepareRuntimePtySpawn(
|
||||
ctx.isClaudeLaunch =
|
||||
!ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command)
|
||||
if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
// Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime.
|
||||
ctx.terminalRuntimeOptions =
|
||||
@@ -134,12 +138,10 @@ export async function prepareRuntimePtySpawn(
|
||||
? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget)
|
||||
: null
|
||||
if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)
|
||||
}
|
||||
if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||||
throw new Error(
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
)
|
||||
throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE)
|
||||
}
|
||||
|
||||
ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true
|
||||
|
||||
@@ -136,6 +136,40 @@ describe('registerRuntimeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects Claude structured tabs to the same-version desktop client', async () => {
|
||||
const claudeTab = {
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:claude-1',
|
||||
title: 'Claude Chat',
|
||||
sessionId: 'claude-1',
|
||||
agent: 'claude',
|
||||
isActive: true
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: vi.fn().mockReturnValue('runtime-1'),
|
||||
restoreStructuredAgentSessionTabs: vi.fn(async () => undefined),
|
||||
listMobileSessionTabs: vi.fn(async () => ({
|
||||
worktree: 'workspace-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: claudeTab.id,
|
||||
activeTabType: 'agent-session',
|
||||
tabGroups: [{ id: 'group-1', activeTabId: claudeTab.id, tabOrder: [claudeTab.id] }],
|
||||
tabs: [claudeTab]
|
||||
}))
|
||||
}
|
||||
|
||||
registerRuntimeHandlers(runtime as never)
|
||||
const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
|
||||
const result = await callRegistration, {
|
||||
method: 'session.tabs.list',
|
||||
params: { worktree: 'id:workspace-1' }
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } })
|
||||
})
|
||||
|
||||
it('registers project group runtime RPC methods for local desktop callers', async () => {
|
||||
const runtime = {
|
||||
syncWindowGraph: vi.fn(),
|
||||
|
||||
+12
-3
@@ -10,7 +10,10 @@ import type {
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows'
|
||||
import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline'
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import {
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import { ALL_RPC_METHODS } from '../runtime/rpc/methods'
|
||||
import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle'
|
||||
@@ -76,7 +79,10 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
clientId: 'desktop-renderer',
|
||||
clientKind: 'runtime',
|
||||
connectionId: desktopSenders.connectionIdFor(event.sender),
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
clientCapabilities: [
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
]
|
||||
}
|
||||
)) as RuntimeRpcResponse<unknown>
|
||||
}
|
||||
@@ -121,7 +127,10 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
clientId: 'desktop-renderer',
|
||||
clientKind: 'runtime',
|
||||
connectionId,
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
clientCapabilities: [
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
]
|
||||
}
|
||||
)
|
||||
.finally(stop)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SDKMessage discriminators from Claude Agent SDK 0.3.231 / Claude Code 2.1.231.
|
||||
// SDKMessage discriminators from Claude Agent SDK 0.3.251 / Claude Code 2.1.258.
|
||||
export const CLAUDE_STREAM_JSON_FRAME_KINDS = [
|
||||
'message:assistant',
|
||||
'message:user',
|
||||
@@ -42,7 +42,15 @@ export const CLAUDE_STREAM_JSON_FRAME_KINDS = [
|
||||
'message:prompt_suggestion',
|
||||
'message:system:mirror_error',
|
||||
'message:system:informational',
|
||||
'message:conversation_reset'
|
||||
'message:conversation_reset',
|
||||
// Queue bookkeeping the CLI emits per client-supplied command uuid. Absent
|
||||
// from the SDK's SDKMessage union, which is why it reached users as raw JSON.
|
||||
'message:command_lifecycle',
|
||||
'message:result:success',
|
||||
'message:result:error_during_execution',
|
||||
'message:result:error_max_turns',
|
||||
'message:result:error_max_budget_usd',
|
||||
'message:result:error_max_structured_output_retries'
|
||||
] as const
|
||||
|
||||
export type ClaudeStreamJsonFrameKind = (typeof CLAUDE_STREAM_JSON_FRAME_KINDS)[number]
|
||||
|
||||
@@ -65,6 +65,29 @@ describe('provider frame classification catalog', () => {
|
||||
).toBe('error-surface')
|
||||
})
|
||||
|
||||
it('keeps command queue bookkeeping off the transcript without hiding a failed one', () => {
|
||||
expect(
|
||||
classifyProviderFrame('claude', 'message:command_lifecycle', {
|
||||
command_uuid: 'command-1',
|
||||
state: 'started'
|
||||
})
|
||||
).toBe('status-chrome')
|
||||
expect(
|
||||
classifyProviderFrame('claude', 'message:command_lifecycle', {
|
||||
command_uuid: 'command-1',
|
||||
state: 'cancelled'
|
||||
})
|
||||
).toBe('status-chrome')
|
||||
// Payload inspection outranks the catalogue, so suppressing the kind cannot
|
||||
// swallow a state the provider reports as a failure.
|
||||
expect(
|
||||
classifyProviderFrame('claude', 'message:command_lifecycle', {
|
||||
command_uuid: 'command-1',
|
||||
state: 'failed'
|
||||
})
|
||||
).toBe('error-surface')
|
||||
})
|
||||
|
||||
it('keeps unknown future frames on the substantive bounded fallback path', () => {
|
||||
expect(classifyProviderFrame('codex', 'notification:future/event', {})).toBe(
|
||||
'timeline-substantive'
|
||||
|
||||
@@ -131,7 +131,18 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = {
|
||||
'message:prompt_suggestion': 'status-chrome',
|
||||
'message:system:mirror_error': 'error-surface',
|
||||
'message:system:informational': 'timeline-substantive',
|
||||
'message:conversation_reset': 'status-chrome'
|
||||
'message:conversation_reset': 'status-chrome',
|
||||
// A `started`/`completed`/`cancelled` state for one queued command uuid and
|
||||
// nothing else; the CLI keeps it out of its own transcript too. A state that
|
||||
// reads as a failure still surfaces, via the payload check in classify.
|
||||
'message:command_lifecycle': 'status-chrome',
|
||||
// The turn-complete signal: lifecycle, never a transcript row. Error subtypes
|
||||
// included — the turn's assistant frames already carry any user-facing text.
|
||||
'message:result:success': 'status-chrome',
|
||||
'message:result:error_during_execution': 'status-chrome',
|
||||
'message:result:error_max_turns': 'status-chrome',
|
||||
'message:result:error_max_budget_usd': 'status-chrome',
|
||||
'message:result:error_max_structured_output_retries': 'status-chrome'
|
||||
}
|
||||
} as const satisfies ProviderFrameClassificationTable
|
||||
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router'
|
||||
|
||||
function adapterOf(
|
||||
releaseAcquisition: StructuredAgentSessionAdapter['releaseAcquisition']
|
||||
): StructuredAgentSessionAdapter {
|
||||
return {
|
||||
acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never),
|
||||
releaseAcquisition,
|
||||
dispatch: vi.fn(),
|
||||
cancelTurn: vi.fn(),
|
||||
answerPrompt: vi.fn(),
|
||||
setOption: vi.fn()
|
||||
} as unknown as StructuredAgentSessionAdapter
|
||||
}
|
||||
|
||||
describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => {
|
||||
it('drops the owner even when its release reports a typed failure', async () => {
|
||||
const failure = new Error('root exited')
|
||||
const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false))
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const identity = { sessionId: 'session-1', agent: 'claude' } as never
|
||||
await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' })
|
||||
|
||||
await expect(router.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBe(failure)
|
||||
// With no owner left, a later release asks every adapter instead of the stale one.
|
||||
await expect(router.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false)
|
||||
expect(claude.releaseAcquisition).toHaveBeenCalledTimes(2)
|
||||
expect(codex.releaseAcquisition).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('StructuredAgentSessionAdapterRouter.closeSession', () => {
|
||||
it('retains the owner after an unproven close so a later retry reaches the same adapter', async () => {
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
const closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
||||
const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' })
|
||||
claude.closeSession = closeSession
|
||||
claude.dispatch = dispatch
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const identity = { sessionId: 'session-1', agent: 'claude' } as never
|
||||
await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' })
|
||||
|
||||
await expect(router.closeSession('session-1')).resolves.toBe(false)
|
||||
await expect(
|
||||
router.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-1',
|
||||
body: {} as never,
|
||||
fence: 1
|
||||
})
|
||||
).resolves.toMatchObject({ state: 'unknown' })
|
||||
await expect(router.closeSession('session-1')).resolves.toBe(true)
|
||||
expect(closeSession).toHaveBeenCalledTimes(2)
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => {
|
||||
it.each([
|
||||
['forceCloseSession', 'forceCloseSession'],
|
||||
['disposeSession', 'disposeSession']
|
||||
] as const)(
|
||||
'%s forwards to the owner and retains it until proven stopped',
|
||||
async (_label, method) => {
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
const stop = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
||||
claude[method] = stop
|
||||
const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' })
|
||||
claude.dispatch = dispatch
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const identity = { sessionId: 'session-1', agent: 'claude' } as never
|
||||
await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' })
|
||||
const stopSession = router[method]
|
||||
|
||||
await expect(stopSession('session-1')).resolves.toBe(false)
|
||||
await expect(
|
||||
router.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-1',
|
||||
body: {} as never,
|
||||
fence: 1
|
||||
})
|
||||
).resolves.toMatchObject({ state: 'unknown' })
|
||||
await expect(stopSession('session-1')).resolves.toBe(true)
|
||||
expect(stop).toHaveBeenCalledTimes(2)
|
||||
expect(dispatch).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['forceCloseSession', 'disposeSession'] as const)(
|
||||
'falls back to closeSession when an owner lacks %s',
|
||||
async (method) => {
|
||||
const closeSession = vi.fn().mockResolvedValue(true)
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
claude.closeSession = closeSession
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
await router.acquire({
|
||||
identity: { sessionId: 'session-1', agent: 'claude' } as never,
|
||||
fence: 1,
|
||||
spawnToken: 'spawn-1'
|
||||
})
|
||||
const stopSession = router[method]
|
||||
|
||||
await expect(stopSession('session-1')).resolves.toBe(true)
|
||||
expect(closeSession).toHaveBeenCalledWith('session-1')
|
||||
}
|
||||
)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user