diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index f3ea33de990..916f02a6cbb 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -7,7 +7,7 @@ 4 test-no-in-test-import-actual:src/main/bitbucket/credential-store.test.ts 1 test-no-in-test-import-actual:src/main/bitbucket/status-no-decrypt.test.ts 2 test-no-in-test-import-actual:src/main/browser/cookies/browser-cookie-import.comet.test.ts -10 test-no-in-test-import-actual:src/main/claude-usage/scanner-scan.test.ts +3 test-no-in-test-import-actual:src/main/claude-usage/scanner-scan.test.ts 1 test-no-in-test-import-actual:src/main/claude/claude-agent-sdk-process-spawn.test.ts 3 test-no-in-test-import-actual:src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts 1 test-no-in-test-import-actual:src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -56,12 +56,11 @@ 1 test-no-polling-loop:src/main/browser/cookies/browser-cookie-import-concurrency.test.ts 1 test-no-polling-loop:src/main/claude/claude-stop-ack-race.test.ts 1 test-no-polling-loop:src/main/claude/claude-stream-json-connection.test.ts -1 test-no-polling-loop:src/main/claude/claude-subagent-permission-request.test.ts 1 test-no-polling-loop:src/main/claude/claude-supervised-stop-real-cli.test.ts 1 test-no-polling-loop:src/main/claude/claude-supervised-stop.integration.test.ts 1 test-no-polling-loop:src/main/claude/structured/claude-structured-real-cli-fold.test.ts 1 test-no-polling-loop:src/main/claude/structured/claude-structured-real-cli-stop-task.test.ts -6 test-no-polling-loop:src/main/claude/structured/claude-structured-real-cli.test.ts +5 test-no-polling-loop:src/main/claude/structured/claude-structured-real-cli.test.ts 1 test-no-polling-loop:src/main/claude/structured/launch/claude-structured-real-cli-options.test.ts 3 test-no-polling-loop:src/main/cli/packaged-cli-assets.test.ts 1 test-no-polling-loop:src/main/codex/hooks/reconciliation/codex-hook-toggle-off-wins.test.ts @@ -211,21 +210,19 @@ 1 test-no-real-time-wait:src/main/browser/network/tunnels/execution-route-socket-duplex.test.ts 1 test-no-real-time-wait:src/main/browser/network/tunnels/remote-browser-socks-server.test.ts 1 test-no-real-time-wait:src/main/claude-accounts/claude-login-session.test.ts -2 test-no-real-time-wait:src/main/claude-accounts/claude-profile-router.test.ts -3 test-no-real-time-wait:src/main/claude-accounts/claude-profile-wsl-router.test.ts +1 test-no-real-time-wait:src/main/claude-accounts/claude-profile-router.test.ts +1 test-no-real-time-wait:src/main/claude-accounts/claude-profile-wsl-router.test.ts 1 test-no-real-time-wait:src/main/claude-accounts/runtime-auth-service.test.ts 1 test-no-real-time-wait:src/main/claude-usage/scanner-incremental-reader.test.ts 1 test-no-real-time-wait:src/main/claude/claude-agent-sdk-exit-proof.test.ts 1 test-no-real-time-wait:src/main/claude/claude-agent-sdk-user-message-queue.test.ts -3 test-no-real-time-wait:src/main/claude/claude-cli-flag-support.test.ts 1 test-no-real-time-wait:src/main/claude/claude-stop-ack-race.test.ts -2 test-no-real-time-wait:src/main/claude/claude-stream-json-connection.test.ts -1 test-no-real-time-wait:src/main/claude/claude-subagent-permission-request.test.ts +1 test-no-real-time-wait:src/main/claude/claude-stream-json-connection.test.ts 1 test-no-real-time-wait:src/main/claude/claude-supervised-stop-real-cli.test.ts 1 test-no-real-time-wait:src/main/claude/claude-supervised-stop.integration.test.ts 2 test-no-real-time-wait:src/main/claude/structured/claude-structured-real-cli-fold.test.ts 2 test-no-real-time-wait:src/main/claude/structured/claude-structured-real-cli-stop-task.test.ts -6 test-no-real-time-wait:src/main/claude/structured/claude-structured-real-cli.test.ts +5 test-no-real-time-wait:src/main/claude/structured/claude-structured-real-cli.test.ts 1 test-no-real-time-wait:src/main/claude/structured/launch/claude-structured-real-cli-options.test.ts 3 test-no-real-time-wait:src/main/cli/packaged-cli-assets.test.ts 3 test-no-real-time-wait:src/main/codex/hooks/installation/hook-service-concurrent-launch-install.test.ts @@ -652,7 +649,6 @@ 2 test-no-wall-clock:src/main/browser/cookies/browser-cookie-import-partition-reland.electron.test.ts 2 test-no-wall-clock:src/main/browser/network/execution-routes/wsl-browser-network-execution-route.test.ts 1 test-no-wall-clock:src/main/claude-accounts/claude-account-folder.test.ts -1 test-no-wall-clock:src/main/claude-usage/persisted-source-authority.test.ts 1 test-no-wall-clock:src/main/claude/claude-background-wake-retention.test.ts 1 test-no-wall-clock:src/main/claude/claude-descendant-escalation-boundary.test.ts 1 test-no-wall-clock:src/main/claude/claude-folder-trust-file.test.ts @@ -661,7 +657,7 @@ 4 test-no-wall-clock:src/main/claude/structured/claude-structured-real-cli-fold.test.ts 1 test-no-wall-clock:src/main/claude/structured/claude-structured-real-cli-rewind.test.ts 3 test-no-wall-clock:src/main/claude/structured/claude-structured-real-cli-stop-task.test.ts -16 test-no-wall-clock:src/main/claude/structured/claude-structured-real-cli.test.ts +10 test-no-wall-clock:src/main/claude/structured/claude-structured-real-cli.test.ts 3 test-no-wall-clock:src/main/claude/structured/launch/claude-structured-real-cli-options.test.ts 1 test-no-wall-clock:src/main/cli/appimage-generation-lease.test.ts 6 test-no-wall-clock:src/main/cli/packaged-cli-assets.test.ts @@ -999,13 +995,11 @@ 1 test-timeout-needs-reason:src/main/browser/guest-lifecycle/find/browser-find-frame-traversal.electron.test.ts 1 test-timeout-needs-reason:src/main/browser/identity/browser-session-ua-wire-identity-cross-context.electron.test.ts 2 test-timeout-needs-reason:src/main/browser/identity/browser-session-ua-wire-identity.electron.test.ts -5 test-timeout-needs-reason:src/main/claude-usage/scanner-incremental-reader.test.ts -4 test-timeout-needs-reason:src/main/claude/claude-agent-sdk-exit-proof.test.ts +3 test-timeout-needs-reason:src/main/claude-usage/scanner-incremental-reader.test.ts +1 test-timeout-needs-reason:src/main/claude/claude-agent-sdk-exit-proof.test.ts 1 test-timeout-needs-reason:src/main/claude/claude-background-run-isolation-real-cli.test.ts -1 test-timeout-needs-reason:src/main/claude/claude-child-exit-proof-ladder.test.ts 2 test-timeout-needs-reason:src/main/claude/claude-status-line-windows.test.ts 1 test-timeout-needs-reason:src/main/claude/claude-stop-ack-race.test.ts -1 test-timeout-needs-reason:src/main/claude/claude-stream-json-connection.test.ts 1 test-timeout-needs-reason:src/main/claude/structured/claude-structured-real-cli.test.ts 1 test-timeout-needs-reason:src/main/claude/windows-native-hook-registration.test.ts 2 test-timeout-needs-reason:src/main/codex/app-server/codex-app-server-teardown.integration.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 000dd8ed319..e01cea3034f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8591,7 +8591,7 @@ "id": "agent-status.manual-compact-identity", "title": "A finished Claude compact clears the pane, and an aborted one never strands it", "maturity": "experimental", - "protection": "partial", + "protection": "none", "owner": "agent-session", "layer": "shared-main-relay-contract", "surfaces": [ @@ -8602,50 +8602,19 @@ ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], - "coveredPlatforms": ["macos", "linux"], - "coveredProviders": ["local", "ssh"], - "coverageNotes": "Audit1595 removes canned hook/installer/status/capture inventories and fixture-owned command scanners without equivalent replacement coverage. Retained checks cover local filesystem/symlink/shell/pipe and TCP deadlines, ACP timeline/journal failures, naming ownership/races and numeric release helper composition; provider/remote/process inventories remain injected. Native Windows cases are platform-gated; no actual remote deployment, Fastlane/Apple API or native provider identity proof. Historical scope before this audit: Audit1340 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Behavior tests deliver only events present in CLAUDE_EVENTS, so a mapping that is not registered cannot pass -- the failure mode that let the earlier compact correlation ship unreachable. Claude Code 2.1.227 PTY captures prove a successful manual compact emits PreCompact, a start-less summarizer SubagentStop, SessionStart(source=compact), then PostCompact, that an aborted compact emits PreCompact ALONE, that /compact emits no UserPromptSubmit, and that session_id is stable across the window. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps. The entire Claude-compatible vendor mapping suite is removed by Audit1297, including remaining vendor normalization samples. Audit1287 removes agent-hook-relay.test.ts, an isolated object/digest suite. Its unavailable-envelope normalization, identity-change and attribution alarms are intentionally lost; retained transport tests are not claimed equivalent. Audit1297: src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-compatible-vendors.test.ts removed. Lose resumed Kimi session capture, harness prompt suppression and Muse permission/question/lifecycle/provider-session samples. No equivalent replacement coverage is claimed. Audit1318: src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-turn-state.test.ts: Synthetic Claude hook/state histories and a normalization/acceptance harness assert option/field mappings, internal caches and private lead-state mutation; no actual Claude lifecycle/cwd/interrupt producer. Intentionally lose resume/compact/clear/cwd/effort/child-wait/cancel restoration and cache-bound alarms. Reduced selectors need fresh validation; historical full-suite receipts do not validate the changed coverage. No equivalent replacement or physical-provider claim. Audit1340: src/main/agent-hooks/manual-compact-status-cleanup.test.ts removed. Late PostCompact cannot revive cleaned status across three cleanup APIs. No equivalent replacement coverage is claimed. Audit1352: focused Linux unit selections validated on source head b638e07d395b76af3ba6b03761a1a0fe7e5e026e. Historical statements above retain their original scope. Intentional test losses are recorded in knownGaps; these runs claim no physical-provider, p95, rendered E2E or cross-platform validation. Audit1360: focused Linux unit selections validated on source head 415c9cffc2d10024131ec4b6721767a22195de6d. Historical statements above retain their original scope. Intentional test losses are recorded in knownGaps; these runs claim no physical-provider, p95, rendered E2E or cross-platform validation.", + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Audit1610 deletes compact-status-registration.test.ts, a mock-only registration inventory. Its alarms are retired without equivalent replacement coverage; this gate has no remaining active assertion references. Historical scope before this audit: Audit1595 removes canned hook/installer/status/capture inventories and fixture-owned command scanners without equivalent replacement coverage. Retained checks cover local filesystem/symlink/shell/pipe and TCP deadlines, ACP timeline/journal failures, naming ownership/races and numeric release helper composition; provider/remote/process inventories remain injected. Native Windows cases are platform-gated; no actual remote deployment, Fastlane/Apple API or native provider identity proof. Historical scope before this audit: Audit1340 changes referenced sources. Retained unchanged-source receipts apply only to their unchanged commands; the reduced selectors have no new execution result. Historical coverage account (not a current claim for changed sources): Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Behavior tests deliver only events present in CLAUDE_EVENTS, so a mapping that is not registered cannot pass -- the failure mode that let the earlier compact correlation ship unreachable. Claude Code 2.1.227 PTY captures prove a successful manual compact emits PreCompact, a start-less summarizer SubagentStop, SessionStart(source=compact), then PostCompact, that an aborted compact emits PreCompact ALONE, that /compact emits no UserPromptSubmit, and that session_id is stable across the window. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps. The entire Claude-compatible vendor mapping suite is removed by Audit1297, including remaining vendor normalization samples. Audit1287 removes agent-hook-relay.test.ts, an isolated object/digest suite. Its unavailable-envelope normalization, identity-change and attribution alarms are intentionally lost; retained transport tests are not claimed equivalent. Audit1297: src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-compatible-vendors.test.ts removed. Lose resumed Kimi session capture, harness prompt suppression and Muse permission/question/lifecycle/provider-session samples. No equivalent replacement coverage is claimed. Audit1318: src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-turn-state.test.ts: Synthetic Claude hook/state histories and a normalization/acceptance harness assert option/field mappings, internal caches and private lead-state mutation; no actual Claude lifecycle/cwd/interrupt producer. Intentionally lose resume/compact/clear/cwd/effort/child-wait/cancel restoration and cache-bound alarms. Reduced selectors need fresh validation; historical full-suite receipts do not validate the changed coverage. No equivalent replacement or physical-provider claim. Audit1340: src/main/agent-hooks/manual-compact-status-cleanup.test.ts removed. Late PostCompact cannot revive cleaned status across three cleanup APIs. No equivalent replacement coverage is claimed. Audit1352: focused Linux unit selections validated on source head b638e07d395b76af3ba6b03761a1a0fe7e5e026e. Historical statements above retain their original scope. Intentional test losses are recorded in knownGaps; these runs claim no physical-provider, p95, rendered E2E or cross-platform validation. Audit1360: focused Linux unit selections validated on source head 415c9cffc2d10024131ec4b6721767a22195de6d. Historical statements above retain their original scope. Intentional test losses are recorded in knownGaps; these runs claim no physical-provider, p95, rendered E2E or cross-platform validation.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/11352", "https://github.com/stablyai/orca/pull/11353" ], "invariant": "PreCompact is never registered and may never move a pane, because it fires before the compact is validated and an aborted compact emits it alone. Only a manual PostCompact may mark done, and it does so as a session boundary so completion-reactive consumers stay out of it; an automatic PostCompact claims nothing and writes no compact-scoped state, since its turn resumes and emits its own Stop. A completion either CLEARS a row or publishes nothing at all: it never creates a row, so a retired pane cannot be resurrected, and it never restates one it cannot clear, because a restated row loses restoredUnconfirmed and restarts the staleness clock. A manual /compact only completes at an idle prompt, so it retires a subagent that exists ONLY as a disk snapshot; live evidence -- a child observed in this runtime, an unclassifiable running background task, a registered session cron -- still holds the pane at working. A completion requires a valid provider prompt UUID, matches the pane owner by connection and provider session -- except on a restored row, which carries the previous connection and may carry no session at all -- and is applied at most once per compact. Ownership is enforced by the client that owns pane identity; a relay forwards and defers, so a cold relay cache cannot swallow the clearing event. A relay predating this contract forwards its own mapping, so ingest drops automatic envelopes, stamps the boundary on manual ones, carries the summarized turn label onto a promptless completion with or without a trigger, and classifies a trigger-stripped replay by payload state while still enforcing ownership. Kimi and other Claude-compatible vendors remain fail-closed.", "oracle": "The retained unchanged-source receipts are the only current execution evidence; reduced-source assertions await validation. Historical oracle account (not a current claim for changed sources): Replay the measured Claude compact streams through a harness gated on the real CLAUDE_EVENTS, plus local HTTP, relay, SSH, and persisted-restore transports. Require the successful manual stream to end done with sessionBoundary set, and require PreCompact delivered directly past the registration gate to produce no normalized event. Drive the restart shape end to end over loopback HTTP from a hydrated row: a pane held only by a snapshot child must reach done with an empty roster, while a pane a runtime child still owns must emit NOTHING and keep its row object identical, so receivedAt and restoredUnconfirmed both survive. Deliver the restored-row cases with the hydrated row still cached at completion time and assert the summarizer published nothing, so the restored-row branch is the branch under test. Require rejection of an automatic completion, a completion with absent prompt UUID, a foreign provider session, a duplicate, and a completion for a pane with no cached row; require an automatic completion to leave the live turn working until its own Stop closes it. Require a relay with a COLD cache to forward the completion while the client still refuses to mint a row it never had. Require ingest to drop legacy automatic envelopes, stamp the boundary on legacy manual ones, carry the summarized turn label onto a promptless completion with and without a trigger, and leave a FINISHED pane finished when a trigger-stripped working replay arrives. Require pane, tab, and server cleanup to revoke compact completion authority. Assert managed hook settings, emitted event order, and the authoritative lastStatusByPaneKey snapshot.", - "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts --reporter=dot", - "Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both" - ], - "testFiles": [ - "src/main/claude/compact-status-registration.test.ts", - "src/wsl-guest/agent-hook-server.test.ts", - "src/main/agent-hooks/server-claude-normalization.test.ts", - "src/main/agent-hooks/server-last-status-write.test.ts" - ], - "assertionRefs": [ - { - "file": "src/main/claude/compact-status-registration.test.ts", - "assertions": [ - "PostCompact is written into the settings Claude reads, and PreCompact is absent from those settings", - "the measured successful manual compact ends a stuck working row as a done with sessionBoundary", - "PreCompact delivered directly past the registration gate produces no normalized event", - "an automatic completion emits nothing, leaves the live turn working, and the turn's own Stop still closes it", - "a completion retires a child that exists only as a disk snapshot, but a runtime child or an active session cron still holds the pane and the completion then publishes nothing at all", - "a completion clears a restored row -- delivered while that hydrated row is still the cached owner -- despite a stale connection id or a missing provider session, but never resurrects a retired pane", - "absent prompt UUID, foreign provider session, and duplicate delivery fail closed" - ] - } - ], - "evidenceRuns": [ - { - "date": "2026-08-17", - "runner": "manual", - "platform": "macos", - "command": "Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both", - "result": "passed", - "durationSeconds": 1080, - "summary": "Claude Code 2.1.227. Aborted compact emitted PreCompact(manual) ALONE with no PostCompact and no SessionStart(compact). Successful compact emitted PreCompact(manual), a start-less SubagentStop (agent_type empty, no matching SubagentStart), SessionStart(source=compact), PostCompact(manual), then Notification(idle_prompt). /compact emitted no UserPromptSubmit in either the TUI or print mode; session_id was stable across the whole window while the compact allocated its own prompt_id." - } - ], + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], "runtimeBudget": { "p95Seconds": 20, "scope": "Historical pre-audit selector budget; changed selectors have no new runtime measurement. Unchanged receipts remain scoped to their own commands." @@ -8668,6 +8637,8 @@ "Keep automatic compact completion disabled until a generation-unique provider identity is proven." ], "knownGaps": [ + "No active assertion references remain after this audit; surviving unaffected receipts record historical scope only.", + "Audit1610 deletes compact-status-registration.test.ts, a mock-only registration inventory. Its alarms are retired without equivalent replacement coverage; this gate has no remaining active assertion references.", "Audit1595 removes canned hook/installer/status/capture inventories and fixture-owned command scanners without equivalent replacement coverage. Retained checks cover local filesystem/symlink/shell/pipe and TCP deadlines, ACP timeline/journal failures, naming ownership/races and numeric release helper composition; provider/remote/process inventories remain injected. Native Windows cases are platform-gated; no actual remote deployment, Fastlane/Apple API or native provider identity proof.", "No live Linux, Windows, WSL, paired-runtime, or real-SSH manual compact journey was run.", "Claude versions before prompt_id support intentionally cannot complete a manual compact row through PostCompact.", @@ -8680,7 +8651,8 @@ "Audit1318 retired the complete dated receipt after source coverage changed. Its result does not validate the reduced selector. Original receipt verbatim:\n{\n \"date\": \"2026-11-04\",\n \"runner\": \"local\",\n \"platform\": \"linux\",\n \"command\": \"ORCA_BACKGROUND_LAUNCH=1 pnpm --config.verifyDepsBeforeRun=false test src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-turn-state.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts --maxWorkers=2\",\n \"currentCommand\": \"pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-turn-state.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts --reporter=dot\",\n \"result\": \"passed\",\n \"durationSeconds\": 4.7,\n \"summary\": \"Test Files 7 passed (7); Tests 87 passed (87). Deterministic fixtures; no physical phone or deployed mixed-host claim.\"\n }", "Audit1340: src/main/agent-hooks/manual-compact-status-cleanup.test.ts removed. Late PostCompact cannot revive cleaned status across three cleanup APIs. No equivalent replacement coverage is claimed.", "Audit1340 historical platform/provider scope before source changes: {\"coveredPlatforms\":[\"macos\",\"linux\"],\"coveredProviders\":[\"local\",\"ssh\"]}. Remaining provider scope applies only to unchanged-source receipts.", - "Audit1340 historical assertion inventory for deleted/reduced sources; not current validated assertions:\n[\n {\n \"file\": \"src/main/agent-hooks/manual-compact-status-cleanup.test.ts\",\n \"assertions\": [\n \"pane, tab, and server cleanup revoke compact completion authority\"\n ]\n }\n]" + "Audit1340 historical assertion inventory for deleted/reduced sources; not current validated assertions:\n[\n {\n \"file\": \"src/main/agent-hooks/manual-compact-status-cleanup.test.ts\",\n \"assertions\": [\n \"pane, tab, and server cleanup revoke compact completion authority\"\n ]\n }\n]", + "Audit1610 retains this receipt as historical scope only after removing the gate’s last active assertion reference. Receipt fields preserved verbatim:\n{\n \"date\": \"2026-08-17\",\n \"runner\": \"manual\",\n \"platform\": \"macos\",\n \"command\": \"Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both\",\n \"result\": \"passed\",\n \"durationSeconds\": 1080,\n \"summary\": \"Claude Code 2.1.227. Aborted compact emitted PreCompact(manual) ALONE with no PostCompact and no SessionStart(compact). Successful compact emitted PreCompact(manual), a start-less SubagentStop (agent_type empty, no matching SubagentStart), SessionStart(source=compact), PostCompact(manual), then Notification(idle_prompt). /compact emitted no UserPromptSubmit in either the TUI or print mode; session_id was stable across the whole window while the compact allocated its own prompt_id.\"\n}" ], "demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug." }, diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index b5d403d0d08..86d48eb6abe 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -357,7 +357,6 @@ "src/cli/handlers/interactive-login-interruption.test.ts": 70, "src/cli/handlers/jira-write.test.ts": 431, "src/cli/handlers/linear.test.ts": 339, - "src/cli/handlers/managed-server.test.ts": 412, "src/cli/handlers/opencode-account-login-command.test.ts": 492, "src/cli/handlers/orchestration-caller-identity-cli.test.ts": 468, "src/cli/handlers/orchestration-check-identity.test.ts": 640, @@ -972,17 +971,14 @@ "src/main/automations/retained-run-reconciliation.test.ts": 1940, "src/main/automations/run-completion-watcher.test.ts": 5875, "src/main/automations/run-target-resolution.test.ts": 367, - "src/main/automations/runtime-automation-service.test.ts": 1389, "src/main/automations/runtime-chat-run-observer.test.ts": 6812, "src/main/automations/runtime-terminal-run-observer-evidence.test.ts": 230, "src/main/automations/runtime-terminal-run-observer.test.ts": 4026, "src/main/automations/schedule-drift-report.test.ts": 83, "src/main/automations/service-precheck.test.ts": 2057, "src/main/automations/service.test.ts": 2970, - "src/main/azure-devops/client.test.ts": 411, "src/main/azure-devops/pull-request-mappers.test.ts": 49, "src/main/azure-devops/repository-ref.test.ts": 392, - "src/main/bitbucket/client.test.ts": 496, "src/main/bitbucket/credential-connection.test.ts": 156, "src/main/bitbucket/credential-store.test.ts": 245, "src/main/bitbucket/pull-request-mappers.test.ts": 44, @@ -1243,7 +1239,6 @@ "src/main/claude-accounts/claude-account-service.test.ts": 1207, "src/main/claude-accounts/claude-command-process-windows-only.test.ts": 90, "src/main/claude-accounts/claude-command-process.test.ts": 118, - "src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts": 821, "src/main/claude-accounts/claude-launch-routing.test.ts": 516, "src/main/claude-accounts/claude-login-session.test.ts": 1048, "src/main/claude-accounts/claude-pasted-code-login.test.ts": 238, @@ -1266,7 +1261,6 @@ "src/main/claude-usage/claude-usage-report-aggregation.test.ts": 57, "src/main/claude-usage/persisted-source-authority.test.ts": 491, "src/main/claude-usage/persisted-token-columns-store.test.ts": 376, - "src/main/claude-usage/persisted-token-columns.test.ts": 174, "src/main/claude-usage/scanner-incremental-append.test.ts": 235, "src/main/claude-usage/scanner-incremental-ownership.test.ts": 545, "src/main/claude-usage/scanner-incremental-reader.test.ts": 819, @@ -1286,7 +1280,6 @@ "src/main/claude/agent-view/agent-view-install.test.ts": 196, "src/main/claude/agent-view/agent-view-mod-source.test.ts": 738, "src/main/claude/agent-view/agent-view-service.test.ts": 403, - "src/main/claude/claude-adapter-capture-replay.test.ts": 995, "src/main/claude/claude-agent-sdk-contract-pins.test.ts": 3506, "src/main/claude/claude-agent-sdk-control-requests.test.ts": 45, "src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts": 134, @@ -1300,16 +1293,11 @@ "src/main/claude/claude-background-stop-records.test.ts": 1249, "src/main/claude/claude-background-task-resume.test.ts": 54, "src/main/claude/claude-background-task-retention.test.ts": 499, - "src/main/claude/claude-background-task-row-journal.test.ts": 132, - "src/main/claude/claude-background-task-rows.test.ts": 197, "src/main/claude/claude-background-task-terminal-frames.test.ts": 103, - "src/main/claude/claude-background-task-tracker.test.ts": 199, "src/main/claude/claude-background-task-turn-resumption.test.ts": 325, "src/main/claude/claude-background-wake-retention.test.ts": 1652, - "src/main/claude/claude-child-exit-proof-ladder.test.ts": 181, "src/main/claude/claude-child-process-environment.test.ts": 52, "src/main/claude/claude-child-work-decoder.test.ts": 110, - "src/main/claude/claude-cli-flag-support.test.ts": 435, "src/main/claude/claude-close-turn-end.test.ts": 970, "src/main/claude/claude-command-lifecycle-frames.test.ts": 268, "src/main/claude/claude-command-lifecycle.test.ts": 1107, @@ -1318,14 +1306,11 @@ "src/main/claude/claude-context-model-switch.test.ts": 422, "src/main/claude/claude-context-usage-restart.test.ts": 925, "src/main/claude/claude-context-usage-unloaded-turn.test.ts": 4910, - "src/main/claude/claude-context-usage.test.ts": 174, - "src/main/claude/claude-context-window-hint.test.ts": 415, "src/main/claude/claude-descendant-escalation-boundary.test.ts": 593, "src/main/claude/claude-diff-panel-auto-open.test.ts": 117, "src/main/claude/claude-fold-steer-receipt.test.ts": 1019, "src/main/claude/claude-folder-trust-file.test.ts": 762, "src/main/claude/claude-hook-decision-script.test.ts": 336, - "src/main/claude/claude-hook-event-install.test.ts": 198, "src/main/claude/claude-hook-event-versions.test.ts": 137, "src/main/claude/claude-informational-row.test.ts": 811, "src/main/claude/claude-journal-turn-scope.test.ts": 254, @@ -1338,7 +1323,6 @@ "src/main/claude/claude-request-end-wait.test.ts": 53, "src/main/claude/claude-rewind-transcript.test.ts": 762, "src/main/claude/claude-settings-schema-install.test.ts": 410, - "src/main/claude/claude-slash-command-catalog.test.ts": 65, "src/main/claude/claude-status-line-install.test.ts": 342, "src/main/claude/claude-status-line-windows.test.ts": 273, "src/main/claude/claude-stop-ack-race.test.ts": 1301, @@ -1353,35 +1337,23 @@ "src/main/claude/claude-subagent-resume-after-restart.test.ts": 825, "src/main/claude/claude-supervised-stop-real-cli.test.ts": 382, "src/main/claude/claude-supervised-stop.integration.test.ts": 3821, - "src/main/claude/claude-thread-goal-journaling.test.ts": 394, "src/main/claude/claude-transcript-catch-up.test.ts": 4017, "src/main/claude/claude-transcript-duration.test.ts": 1411, "src/main/claude/claude-transcript-entry-uuid.test.ts": 38, - "src/main/claude/claude-turn-outcome.test.ts": 408, - "src/main/claude/claude-turn-ownership.test.ts": 1103, - "src/main/claude/claude-turn-resumption.test.ts": 333, - "src/main/claude/claude-usage-limits.test.ts": 885, - "src/main/claude/compact-status-registration.test.ts": 284, "src/main/claude/hook-service.test.ts": 329, "src/main/claude/post-compact-hook-command.test.ts": 314, "src/main/claude/statusline-script.test.ts": 257, - "src/main/claude/structured/claude-structured-child-work-captures.test.ts": 1404, - "src/main/claude/structured/claude-structured-child-work-producer.test.ts": 1281, "src/main/claude/structured/claude-structured-content-parts.test.ts": 277, - "src/main/claude/structured/claude-structured-control-actions.test.ts": 485, "src/main/claude/structured/claude-structured-conversation-stop.test.ts": 884, "src/main/claude/structured/claude-structured-dispatch-admission.test.ts": 426, "src/main/claude/structured/claude-structured-dispatch-attachment-rejection.test.ts": 493, "src/main/claude/structured/claude-structured-dispatch-content.test.ts": 176, "src/main/claude/structured/claude-structured-dispatch-queue-boundary.test.ts": 911, - "src/main/claude/structured/claude-structured-dispatch.test.ts": 1852, - "src/main/claude/structured/claude-structured-effort-default-at-rest.test.ts": 1206, "src/main/claude/structured/claude-structured-effort-reporting.test.ts": 1127, "src/main/claude/structured/claude-structured-execution-recording.test.ts": 2228, "src/main/claude/structured/claude-structured-in-turn-stop.test.ts": 1017, "src/main/claude/structured/claude-structured-inbound-control.test.ts": 55, "src/main/claude/structured/claude-structured-option-confirmation.test.ts": 919, - "src/main/claude/structured/claude-structured-queued-stop.test.ts": 913, "src/main/claude/structured/claude-structured-real-cli-fold.test.ts": 921, "src/main/claude/structured/claude-structured-real-cli-rewind.test.ts": 933, "src/main/claude/structured/claude-structured-real-cli-stop-task.test.ts": 888, @@ -1392,22 +1364,15 @@ "src/main/claude/structured/claude-structured-requested-stop.test.ts": 925, "src/main/claude/structured/claude-structured-resume-point.test.ts": 1713, "src/main/claude/structured/claude-structured-session-acquisition-processless.test.ts": 778, - "src/main/claude/structured/claude-structured-session-adapter-turns.test.ts": 1108, "src/main/claude/structured/claude-structured-session-adapter.test.ts": 1034, "src/main/claude/structured/claude-structured-session-close.test.ts": 1040, - "src/main/claude/structured/claude-structured-session-commands.test.ts": 1130, "src/main/claude/structured/claude-structured-session-exit-blame.test.ts": 1176, - "src/main/claude/structured/claude-structured-session-reading-control.test.ts": 1032, - "src/main/claude/structured/claude-structured-session-recovery.test.ts": 953, "src/main/claude/structured/claude-structured-session-startup.test.ts": 1002, - "src/main/claude/structured/claude-structured-sign-in.test.ts": 303, "src/main/claude/structured/claude-structured-turn-resume-point.test.ts": 1989, "src/main/claude/structured/journal/claude-structured-journal-prompt-cards.test.ts": 62, "src/main/claude/structured/journal/claude-structured-journal-prompt-retry.test.ts": 282, - "src/main/claude/structured/journal/claude-structured-journal-translation-background-tasks.test.ts": 416, "src/main/claude/structured/journal/claude-structured-journal-translation-turn-timing.test.ts": 827, "src/main/claude/structured/journal/claude-structured-journal-translation.test.ts": 771, - "src/main/claude/structured/journal/claude-structured-prompt-items.test.ts": 252, "src/main/claude/structured/journal/claude-structured-prompt-ownership.test.ts": 944, "src/main/claude/structured/launch/claude-structured-applied-options.test.ts": 956, "src/main/claude/structured/launch/claude-structured-child-env.test.ts": 126, diff --git a/src/cli/handlers/managed-server.test.ts b/src/cli/handlers/managed-server.test.ts deleted file mode 100644 index 2b5b60ed0e3..00000000000 --- a/src/cli/handlers/managed-server.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ORCAD_RECOVERY_CHANGED_STATE_CODE } from '../../shared/orcad-managed-runtime' -import { MANAGED_SERVER_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { RuntimeClientError } from '../runtime-client' -import { MANAGED_SERVER_HANDLERS } from './managed-server' - -function envelope(result: unknown) { - return { id: 'r', ok: true, result, _meta: { runtimeId: 'runtime-1' } } -} - -function client(result: unknown, capabilities = [MANAGED_SERVER_RUNTIME_CAPABILITY]) { - return vi.fn(async (method: string) => - method === 'status.get' ? envelope({ capabilities }) : envelope(result) - ) -} - -async function run( - command: string, - call: ReturnType, - flags: [string, string | boolean][] -) { - const handler = MANAGED_SERVER_HANDLERS[command] - if (!handler) { - throw new Error(`no handler for ${command}`) - } - await handler({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers only call client.call. - client: { call } as never, - cwd: '/tmp', - flags: new Map([['environment', 'build-box'], ...flags]), - json: false - }) -} - -afterEach(() => vi.restoreAllMocks()) - -describe('managed server CLI verbs', () => { - it('refuses on a runtime that does not advertise managed servers, before calling the action', async () => { - const call = client({ outcome: 'none' }, []) - await expect(run('environment recover', call, [])).rejects.toMatchObject({ - code: 'incompatible_runtime' - }) - expect(call).toHaveBeenCalledTimes(1) - }) - - it('reads an older runtime’s method_not_found as the same refusal', async () => { - const call = vi.fn(async (method: string) => { - if (method === 'status.get') { - return envelope({ capabilities: [MANAGED_SERVER_RUNTIME_CAPABILITY] }) - } - throw new RuntimeClientError('method_not_found', 'Unknown method') - }) - await expect(run('environment status', call, [])).rejects.toMatchObject({ - code: 'incompatible_runtime' - }) - }) - - it('needs --yes before stopping, and then calls the same stop the settings use', async () => { - const stopped = { - outcome: 'unlinked', - verdict: 'exited', - environmentId: 'env-1', - sshTargetId: 'ssh-1', - stoppedVersion: '1.0.0', - retirement: null - } - const unconfirmed = client(stopped) - await expect(run('environment stop', unconfirmed, [])).rejects.toMatchObject({ - code: 'confirmation_required' - }) - expect(unconfirmed).not.toHaveBeenCalled() - - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) - const confirmed = client(stopped) - await run('environment stop', confirmed, [['yes', true]]) - expect(confirmed).toHaveBeenCalledWith( - 'managedServer.stop', - { selector: 'build-box' }, - { timeoutMs: 1_200_000 } - ) - expect(log).toHaveBeenCalledWith(expect.stringContaining('build-box')) - }) - - it('forgets a server only with --yes, over its own method (P1-E)', async () => { - const forgotten = { - outcome: 'forgotten', - verdict: 'unverifiable', - environmentId: 'env-1', - sshTargetId: 'ssh-1' - } - const unconfirmed = client(forgotten) - await expect(run('environment forget', unconfirmed, [])).rejects.toMatchObject({ - code: 'confirmation_required' - }) - expect(unconfirmed).not.toHaveBeenCalled() - - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) - const confirmed = client(forgotten) - await run('environment forget', confirmed, [['yes', true]]) - // Older desktops ignore flags; using stop for forget would decommission the server. - expect(confirmed).toHaveBeenCalledWith( - 'managedServer.forget', - { selector: 'build-box' }, - { timeoutMs: 1_200_000 } - ) - expect(log).toHaveBeenCalledWith(expect.stringContaining('may still be running there')) - }) - - it('fails the command with the refusal, so scripts see a non-zero exit', async () => { - const refusal = { - outcome: 'refused', - verdict: 'live', - code: 'orcad_stop_active_environment', - reason: - 'This server is the default host for new projects. Choose another one under New projects open on before stopping it.' - } - await expect(run('environment stop', client(refusal), [['yes', true]])).rejects.toMatchObject({ - code: 'managed_server_refused', - message: refusal.reason, - data: refusal - }) - }) - - it('passes --force to an update, and reports a deferred one as unsettled', async () => { - const call = client({ - outcome: 'deferred', - candidateVersion: '2.0.0', - code: 'orcad_update_terminals_running', - reason: 'Terminals are running.' - }) - await expect(run('environment update', call, [])).rejects.toMatchObject({ - code: 'managed_server_deferred' - }) - expect(call).toHaveBeenCalledWith( - 'managedServer.update', - { selector: 'build-box', force: false }, - { timeoutMs: 1_200_000 } - ) - - vi.spyOn(console, 'log').mockImplementation(() => undefined) - const forced = client({ - outcome: 'updated', - environment: { name: 'build-box' }, - activeVersion: '2.0.0' - }) - await run('environment update', forced, [['force', true]]) - expect(forced).toHaveBeenCalledWith( - 'managedServer.update', - { selector: 'build-box', force: true }, - { timeoutMs: 1_200_000 } - ) - }) - - it('fails on an outcome a newer desktop added instead of printing undefined', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) - await expect( - run('environment cancel-stop', client({ outcome: 'future-outcome' }), []) - ).rejects.toMatchObject({ code: 'managed_server_future-outcome' }) - expect(log).not.toHaveBeenCalled() - }) - - it('prints a readable status', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) - const call = client({ - activeVersion: '1.0.0', - previousVersion: '0.9.0', - rollbackAvailable: true, - recovery: null, - terminals: { liveSessions: null }, - migration: null, - deferredUpdate: null - }) - await run('environment status', call, []) - expect(call).toHaveBeenCalledWith('managedServer.status', { selector: 'build-box' }, undefined) - expect(log.mock.calls[0]?.[0]).toBe( - 'Active version: 1.0.0\nPrevious version: 0.9.0 (rollback available)\nLive terminals: unverifiable' - ) - }) - - it('reports a timed-out action as possibly still running, never as a failure of the action', async () => { - const call = vi.fn(async (method: string) => { - if (method === 'status.get') { - return envelope({ capabilities: [MANAGED_SERVER_RUNTIME_CAPABILITY] }) - } - throw new RuntimeClientError( - 'runtime_timeout', - 'Timed out waiting for the Orca runtime to respond.' - ) - }) - await expect(run('environment update', call, [])).rejects.toMatchObject({ - code: 'managed_server_in_progress', - message: expect.stringContaining('orca environment status') - }) - }) - - it('reports what a restarting update left behind instead of failing on the closed connection', async () => { - vi.useFakeTimers() - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - let updated = false - const call = vi.fn(async (method: string) => { - if (method === 'status.get') { - return envelope({ capabilities: [MANAGED_SERVER_RUNTIME_CAPABILITY] }) - } - if (method === 'managedServer.update') { - updated = true - throw new RuntimeClientError( - 'runtime_unavailable', - 'The Orca runtime closed the connection before responding.' - ) - } - return envelope({ activeVersion: '0.2.0+bb01', recovery: null, deferredUpdate: null }) - }) - try { - const done = run('environment update', call, []) - await vi.advanceTimersByTimeAsync(2_000) - await done - expect(updated).toBe(true) - expect(log).toHaveBeenCalledWith(expect.stringContaining('now runs 0.2.0+bb01')) - } finally { - vi.useRealTimers() - } - }) - - it('reports an update the restart left interrupted as needing recover', async () => { - vi.useFakeTimers() - const call = vi.fn(async (method: string) => { - if (method === 'status.get') { - return envelope({ capabilities: [MANAGED_SERVER_RUNTIME_CAPABILITY] }) - } - if (method === 'managedServer.update') { - throw new RuntimeClientError('runtime_unavailable', 'closed') - } - return envelope({ - activeVersion: '0.1.0+aa01', - recovery: { operation: 'activate', version: '0.2.0+bb01', phase: 'snapshot-captured' }, - deferredUpdate: null - }) - }) - try { - const done = run('environment update', call, []).catch((error: unknown) => error) - await vi.advanceTimersByTimeAsync(2_000) - expect(await done).toMatchObject({ code: 'managed_server_interrupted' }) - } finally { - vi.useRealTimers() - } - }) - - it('restores changed state only with --accept-changed-state --yes, and its refusal names the flags', async () => { - const refusal = { - outcome: 'refused', - verdict: 'unverifiable', - code: ORCAD_RECOVERY_CHANGED_STATE_CODE, - reason: 'The launched build changed profile state. Recover to restore the prelaunch snapshot.' - } - await expect(run('environment recover', client(refusal), [])).rejects.toMatchObject({ - code: 'managed_server_refused', - message: expect.stringContaining('--accept-changed-state --yes') - }) - - const unconfirmed = client({ outcome: 'none' }) - await expect( - run('environment recover', unconfirmed, [['accept-changed-state', true]]) - ).rejects.toMatchObject({ code: 'confirmation_required' }) - expect(unconfirmed).not.toHaveBeenCalled() - - vi.spyOn(console, 'log').mockImplementation(() => undefined) - const confirmed = client({ outcome: 'none' }) - await run('environment recover', confirmed, [ - ['accept-changed-state', true], - ['yes', true] - ]) - expect(confirmed).toHaveBeenCalledWith( - 'managedServer.recover', - { selector: 'build-box', acceptChangedState: true }, - { timeoutMs: 1_200_000 } - ) - }) -}) diff --git a/src/cli/runtime/serve-supervised-service.test.ts b/src/cli/runtime/serve-supervised-service.test.ts index 250deceb66d..195cb534bfe 100644 --- a/src/cli/runtime/serve-supervised-service.test.ts +++ b/src/cli/runtime/serve-supervised-service.test.ts @@ -2,26 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - getServeSuperviseValidationError, - superviseServeService, - SERVE_INSTALL_HOLD_POLL_MS, - type SupervisedServeOptions -} from './serve-supervised-service' -import { - readServeSupervisorBrake, - recordSupervisedFailure, - emptyServeSupervisorBrake, - SERVE_SUPERVISOR_FAILURE_LIMIT, - SERVE_SUPERVISOR_FAILURE_WINDOW_MS, - SERVE_SUPERVISOR_BACKOFF_MAX_MS, - SERVE_GAVE_UP_ADVICE, - type ServeSupervisorBrake -} from './serve-supervisor-brake' -import type { ServeInstallEvidence } from './serve-install-hold' -import { resolveOrcadExitCode } from '../../main/orcad/orcad-exit-code' -import { OrcadInstanceLockError } from '../../main/orcad/orcad-data-root-privacy' -import { OrcadTerminalsNotDurableError } from '../../main/orcad/orcad-serve-supervision' +import { superviseServeService, type SupervisedServeOptions } from './serve-supervised-service' +import { readServeSupervisorBrake, SERVE_SUPERVISOR_FAILURE_LIMIT } from './serve-supervisor-brake' /** Virtual clock: timers fire in order as soon as the loop awaits them. */ function harness(overrides: Partial & { userDataPath: string }) { @@ -102,290 +84,5 @@ describe('superviseServeService', () => { expect(again.logs.join('\n')).toContain('--force') }) - it('names a missing terminal daemon as the reason it gave up', async () => { - const exitCode = resolveOrcadExitCode(new OrcadTerminalsNotDurableError('spawn failed')) - const h = harness({ userDataPath, runOnce: async () => exitCode }) - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(readServeSupervisorBrake(userDataPath)?.gaveUp?.reason).toContain( - 'the terminal daemon did not start' - ) - }) - - it('starts over for a new build or with --force', async () => { - const gaveUp: ServeSupervisorBrake = { - ...emptyServeSupervisorBrake('build-a'), - gaveUp: { at: 1, reason: 'old' } - } - let runs = 0 - const newBuild = harness({ - userDataPath, - readBrake: () => gaveUp, - writeBrake: () => undefined, - buildIdentity: () => 'build-b', - runOnce: async () => { - runs++ - newBuild.stop() - return 0 - } - }) - await expect(superviseServeService(newBuild.options)).resolves.toBe(0) - expect(runs).toBe(1) - - let cleared = false - const forced = harness({ - userDataPath, - force: true, - readBrake: () => gaveUp, - clearBrake: () => { - cleared = true - }, - runOnce: async () => { - runs++ - forced.stop() - return 0 - } - }) - await expect(superviseServeService(forced.options)).resolves.toBe(0) - expect(cleared).toBe(true) - expect(runs).toBe(2) - }) - - it('keeps a give-up while the executable is missing mid-install', async () => { - const gaveUp: ServeSupervisorBrake = { - ...emptyServeSupervisorBrake('build-a'), - gaveUp: { at: 1, reason: 'old' } - } - const identities = ['missing', 'build-a'] - const evidence: ServeInstallEvidence[] = [{ kind: 'hold', reason: 'bundle-missing' }] - let runs = 0 - const h = harness({ - userDataPath, - readBrake: () => gaveUp, - buildIdentity: () => identities.shift() ?? 'build-a', - readInstallEvidence: async () => evidence.shift() ?? { kind: 'clear' }, - runOnce: async () => (runs++, 0) - }) - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(runs).toBe(0) - expect(h.logs.join('\n')).toContain('Not starting') - expect(h.logs.join('\n')).toContain(SERVE_GAVE_UP_ADVICE) - }) - - it('never spawns while the installer holds the bundle, even when the check fails', async () => { - const evidence: ServeInstallEvidence[] = [ - { kind: 'hold', reason: 'installer-running' }, - { kind: 'hold', reason: 'installer-unknown' }, - { kind: 'hold', reason: 'bundle-missing' }, - { kind: 'clear' } - ] - let runs = 0 - const h = harness({ - userDataPath, - readInstallEvidence: async () => evidence.shift() ?? { kind: 'clear' }, - runOnce: async () => { - runs++ - expect(evidence).toHaveLength(0) - h.stop() - return 0 - } - }) - await superviseServeService(h.options) - expect(runs).toBe(1) - expect(h.sleeps).toEqual([ - SERVE_INSTALL_HOLD_POLL_MS, - SERVE_INSTALL_HOLD_POLL_MS, - SERVE_INSTALL_HOLD_POLL_MS - ]) - }) - - it('hands the restart to the service manager when the executable stays gone', async () => { - let runs = 0 - const h = harness({ - userDataPath, - readInstallEvidence: async () => ({ kind: 'hold', reason: 'bundle-missing' }), - runOnce: async () => (runs++, 0) - }) - await expect(superviseServeService(h.options)).resolves.toBe(1) - expect(runs).toBe(0) - }) - - it('waits for a live peer without counting it as a failure', async () => { - let runs = 0 - const h = harness({ - userDataPath, - runOnce: async () => { - runs++ - if (runs === 20) { - h.stop() - } - return 3 - } - }) - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(runs).toBe(20) - expect(readServeSupervisorBrake(userDataPath)).toBeNull() - expect(Math.max(...h.sleeps)).toBe(60_000) - }) - // #32177: a lock this machine cannot judge (a shared home) is a peer, not a fault to give up on. - it('waits on a lock holder it cannot judge, without giving up', async () => { - const holderUnverifiable = resolveOrcadExitCode( - new OrcadInstanceLockError('orcad_instance_lock_holder_unverifiable', 'shared home') - ) - let runs = 0 - const h = harness({ - userDataPath, - runOnce: async () => { - runs++ - if (runs === 10) { - h.stop() - } - return holderUnverifiable - } - }) - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(runs).toBe(10) - expect(readServeSupervisorBrake(userDataPath)).toBeNull() - }) - - it('counts a thrown child failure and keeps supervising', async () => { - let runs = 0 - const h = harness({ - userDataPath, - runOnce: async () => { - runs++ - if (runs === 1) { - throw new Error('Orca serve exited via SIGKILL.') - } - h.stop() - return 0 - } - }) - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(runs).toBe(2) - expect(h.logs).toContain('[serve] Orca serve exited via SIGKILL; restarting in 1s.') - }) - - it('restarts from a replaced AppImage before starting a child, not after it', async () => { - const order: string[] = [] - const h = harness({ - userDataPath, - runOnce: async () => (order.push('spawn'), 0), - appImageUpdate: { - inspect: () => 'superseded', - restart: async () => { - order.push('restart') - h.stop() - return 'the service is stopping' - } - } - }) - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(order).toEqual(['restart']) - }) - - it('does not let a give-up on the old build block a replaced AppImage', async () => { - let restarts = 0 - const h = harness({ - userDataPath, - readBrake: () => ({ - ...emptyServeSupervisorBrake('build-a'), - gaveUp: { at: 1, reason: 'crashed' } - }), - appImageUpdate: { - inspect: () => 'superseded', - restart: async () => { - restarts++ - h.stop() - return 'the service is stopping' - } - } - }) - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(restarts).toBe(1) - expect(h.logs.join('\n')).not.toContain('Not starting') - }) - - it('keeps serving the running build when the restart fails, without retrying it', async () => { - let runs = 0 - let restarts = 0 - const h = harness({ - userDataPath, - runOnce: async () => { - if (++runs === 2) { - h.stop() - } - return 1 - }, - appImageUpdate: { - inspect: () => 'superseded', - restart: async () => (restarts++, 'the new AppImage could not be unpacked') - } - }) - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(restarts).toBe(1) - expect(runs).toBe(2) - expect(h.logs).toContain( - '[serve] Kept the running build: the new AppImage could not be unpacked.' - ) - }) - - it('waits for a half-copied AppImage instead of starting either build', async () => { - const states = ['settling', 'settling', 'current'] as const - let inspections = 0 - let runs = 0 - const h = harness({ - userDataPath, - runOnce: async () => (runs++, h.stop(), 0), - appImageUpdate: { - inspect: () => states[Math.min(inspections++, states.length - 1)]!, - restart: async () => 'unused' - } - }) - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(h.sleeps).toEqual([SERVE_INSTALL_HOLD_POLL_MS, SERVE_INSTALL_HOLD_POLL_MS]) - expect(runs).toBe(1) - }) - - it('exits 0 on a stop signal during backoff without starting again', async () => { - let runs = 0 - const h = harness({ userDataPath, runOnce: async () => (runs++, 1) }) - h.options.setTimer = () => { - queueMicrotask(() => h.stop()) - return () => undefined - } - await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(runs).toBe(1) - }) - - it('refuses --supervise on Windows and with --recipe-json', () => { - expect( - getServeSuperviseValidationError({ supervise: true, recipeJson: false, platform: 'win32' }) - ).toContain('Windows') - expect( - getServeSuperviseValidationError({ supervise: true, recipeJson: true, platform: 'linux' }) - ).toContain('--recipe-json') - expect( - getServeSuperviseValidationError({ supervise: false, recipeJson: true, platform: 'win32' }) - ).toBeNull() - }) -}) - -describe('recordSupervisedFailure', () => { - it('ages failures out of the window and caps the delay', () => { - let record = emptyServeSupervisorBrake('b') - let now = 0 - const delays: number[] = [] - for (let i = 0; i < 20; i++) { - now += SERVE_SUPERVISOR_FAILURE_WINDOW_MS / 4 - const next = recordSupervisedFailure(record, { now, reason: 'x', random: () => 0.5 }) - expect(next.action.kind).toBe('restart') - if (next.action.kind === 'restart') { - delays.push(next.action.delayMs) - } - record = next.record - } - expect(Math.max(...delays)).toBeLessThanOrEqual(SERVE_SUPERVISOR_BACKOFF_MAX_MS) - expect(record.failures.length).toBeLessThan(SERVE_SUPERVISOR_FAILURE_LIMIT) - }) }) diff --git a/src/main/agent-hooks/server-claude-permission-batch-release.test.ts b/src/main/agent-hooks/server-claude-permission-batch-release.test.ts index e4f640be8d6..d7edeca89c0 100644 --- a/src/main/agent-hooks/server-claude-permission-batch-release.test.ts +++ b/src/main/agent-hooks/server-claude-permission-batch-release.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' import { buildBody, PANE } from './server.test-fixtures' -import capture from '../claude/__fixtures__/claude-self-denied-permission-hooks.json' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -54,94 +53,6 @@ async function replayToRow(events: readonly HookEvent[]): Promise<{ } describe('Claude permission wait that Claude denies itself', () => { - it("ends the lead's wait when its tool batch ends", async () => { - expect(await replay(capture.leadDenied)).toEqual([ - 'SessionStart:done', - 'UserPromptSubmit:working', - 'PreToolUse:working', - 'PostToolUse:working', - 'PreToolUse:working', - 'PermissionRequest:waiting', - 'PostToolBatch:working', - 'PreToolUse:working', - 'PostToolUse:working', - 'PostToolBatch:working', - 'Stop:done', - 'SessionEnd:done' - ]) - }) - - it("ends a subagent's wait only when that subagent's batch ends", async () => { - expect(await replay(capture.childDenied)).toEqual([ - 'SessionStart:done', - 'UserPromptSubmit:working', - 'PreToolUse:working', - 'SubagentStart:working', - 'PreToolUse:working', - 'PermissionRequest:waiting', - // A sibling call in the same batch must not clear the prompt. - 'PreToolUse:waiting', - 'PostToolUse:waiting', - 'PostToolBatch:working', - 'SubagentStop:working', - 'PostToolUse:working', - 'PostToolBatch:working', - 'Stop:done', - 'SessionEnd:done' - ]) - }) - - it('ends a wait for one of several parallel calls whose hooks interleave', async () => { - for (const events of [capture.leadParallelReads, capture.childParallelReads]) { - const states = await replay(events) - const batchEnd = events.findIndex((event) => event.hook_event_name === 'PostToolBatch') - expect(states[batchEnd - 1]).not.toBe('PostToolBatch:working') - expect(states[batchEnd]).toBe('PostToolBatch:working') - } - }) - - it("keeps a subagent's prompt after its sibling call finishes and the lead works on", async () => { - const outside = { file_path: '/private/tmp/outside-x' } - const child = { agent_id: 'agent-child', agent_type: 'general-purpose' } - const states = await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { hook_event_name: 'SubagentStart', ...child }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: { file_path: '/home/dev/project/z.ts' }, - tool_use_id: 'toolu-z', - ...child - }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: outside, - tool_use_id: 'toolu-x', - ...child - }, - { hook_event_name: 'PermissionRequest', tool_name: 'Read', tool_input: outside, ...child }, - { - hook_event_name: 'PostToolUse', - tool_name: 'Read', - tool_input: { file_path: '/home/dev/project/z.ts' }, - tool_use_id: 'toolu-z', - ...child - }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'ls' }, - tool_use_id: 'toolu-ls' - } - ]) - expect(states.slice(-3)).toEqual([ - 'PermissionRequest:waiting', - 'PostToolUse:waiting', - 'PreToolUse:waiting' - ]) - }) - it("keeps the pane waiting when a subagent's request ends while the lead's own prompt is open", async () => { const leadCommand = { command: 'touch /private/tmp/orca-permcap-lead' } const childCommand = { command: 'touch /private/tmp/orca-permcap-child' } @@ -212,85 +123,6 @@ describe('Claude permission wait that Claude denies itself', () => { expect(states.at(-1)).toBe('PostToolBatch:waiting') }) - it('keeps a subagent prompt while the lead works and another subagent request ends', async () => { - const childA = { agent_id: 'agent-a', agent_type: 'general-purpose' } - const childB = { agent_id: 'agent-b', agent_type: 'general-purpose' } - const commandA = { command: 'touch /private/tmp/orca-permcap-a' } - const commandB = { command: 'touch /private/tmp/orca-permcap-b' } - const states = await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: commandA, - tool_use_id: 'toolu-a', - ...childA - }, - { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: commandA, ...childA }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: commandB, - tool_use_id: 'toolu-b', - ...childB - }, - { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: commandB, ...childB }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'ls' }, - tool_use_id: 'toolu-ls' - }, - { - hook_event_name: 'PostToolBatch', - tool_calls: [{ tool_name: 'Bash', tool_input: commandB, tool_use_id: 'toolu-b' }], - ...childB - }, - { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'ls' }, - tool_use_id: 'toolu-ls' - } - ]) - expect(states.slice(-2)).toEqual(['PostToolBatch:waiting', 'PostToolUse:waiting']) - }) - - it("ends a subagent's self-denied wait after an earlier subagent stopped with its prompt open", async () => { - const childA = { agent_id: 'agent-a', agent_type: 'general-purpose' } - const childB = { agent_id: 'agent-b', agent_type: 'general-purpose' } - const commandA = { command: 'touch /private/tmp/orca-permcap-a' } - const commandB = { command: 'touch /private/tmp/orca-permcap-b' } - const states = await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { hook_event_name: 'SubagentStart', ...childA }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: commandA, - tool_use_id: 'toolu-a', - ...childA - }, - { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: commandA, ...childA }, - { hook_event_name: 'SubagentStop', ...childA }, - { hook_event_name: 'SubagentStart', ...childB }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: commandB, - tool_use_id: 'toolu-b', - ...childB - }, - { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: commandB, ...childB }, - { - hook_event_name: 'PostToolBatch', - tool_calls: [{ tool_name: 'Bash', tool_input: commandB, tool_use_id: 'toolu-b' }], - ...childB - } - ]) - expect(states.at(-1)).toBe('PostToolBatch:working') - }) - it("keeps the pane waiting on the lead's unanswered question when a subagent's request ends", async () => { const child = { agent_id: 'agent-child', agent_type: 'general-purpose' } const command = { command: 'touch /private/tmp/orca-permcap-q' } @@ -321,46 +153,6 @@ describe('Claude permission wait that Claude denies itself', () => { expect(states.at(-1)).toBe('PostToolBatch:waiting') }) - it("keeps the lead's open prompt however many subagent calls follow it", async () => { - const child = { agent_id: 'agent-child', agent_type: 'general-purpose' } - const leadCommand = { command: 'touch /private/tmp/orca-permcap-lead' } - const reads = Array.from({ length: 40 }, (_, index) => ({ - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: { file_path: `/tmp/file-${index}` }, - tool_use_id: `toolu-read-${index}`, - ...child - })) - const last = reads.at(-1)! - const states = await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: leadCommand, - tool_use_id: 'toolu-lead' - }, - { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: leadCommand }, - ...reads, - { - hook_event_name: 'PermissionRequest', - tool_name: 'Read', - tool_input: last.tool_input, - ...child - }, - { - hook_event_name: 'PostToolBatch', - tool_calls: reads.map(({ tool_name, tool_input, tool_use_id }) => ({ - tool_name, - tool_input, - tool_use_id - })), - ...child - } - ]) - expect(states.at(-1)).toBe('PostToolBatch:waiting') - }) - it("keeps an open MCP dialog when a subagent's self-denied request ends", async () => { const child = { agent_id: 'agent-a', agent_type: 'general-purpose' } const command = { command: 'touch /private/tmp/orca-permcap-mcp' } @@ -423,53 +215,6 @@ describe('Claude permission wait that Claude denies itself', () => { expect(states.at(-1)).toBe('PostToolBatch:waiting') }) - it("ends a subagent's self-denied wait when the listener has no earlier record of the lead", async () => { - const child = { agent_id: 'agent-child', agent_type: 'general-purpose' } - const command = { command: 'touch /private/tmp/orca-permcap-fresh' } - const states = await replay([ - { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: command, - tool_use_id: 'toolu-x', - ...child - }, - { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: command, ...child }, - { - hook_event_name: 'PostToolBatch', - tool_calls: [{ tool_name: 'Bash', tool_input: command, tool_use_id: 'toolu-x' }], - ...child - } - ]) - expect(states.slice(-2)).toEqual(['PermissionRequest:waiting', 'PostToolBatch:working']) - }) - - it("keeps the lead's wait when a background subagent's batch ends", async () => { - const leadWait = capture.leadDenied.slice( - 0, - capture.leadDenied.findIndex((event) => event.hook_event_name === 'PermissionRequest') + 1 - ) - const childBatchEnd = capture.childDenied.find( - (event) => event.hook_event_name === 'PostToolBatch' && 'agent_id' in event - ) - expect(childBatchEnd).toBeDefined() - const states = await replay([...leadWait, childBatchEnd!]) - expect(states.at(-1)).toBe('PostToolBatch:waiting') - }) - - it("keeps a background subagent's wait when the lead's batch ends", async () => { - const childWait = capture.childDenied.slice( - 0, - capture.childDenied.findIndex((event) => event.hook_event_name === 'PermissionRequest') + 1 - ) - const leadBatchEnd = capture.childDenied.find( - (event) => event.hook_event_name === 'PostToolBatch' && !('agent_id' in event) - ) - expect(leadBatchEnd).toBeDefined() - const states = await replay([...childWait, leadBatchEnd!]) - expect(states.at(-1)).toBe('PostToolBatch:waiting') - }) - it('keeps a newer prompt when an earlier batch from the same agent arrives late', async () => { const command = { command: 'touch /private/tmp/orca-permcap-newer' } const states = await replay([ @@ -491,31 +236,6 @@ describe('Claude permission wait that Claude denies itself', () => { expect(states.at(-1)).toBe('PostToolBatch:waiting') }) - it('keeps an open MCP dialog and a finished turn when a batch ends', async () => { - const batch = { - hook_event_name: 'PostToolBatch', - tool_calls: [{ tool_name: 'Bash', tool_input: { command: 'ls' }, tool_use_id: 'toolu-ls' }] - } - expect( - ( - await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { hook_event_name: 'Elicitation', mcp_server_name: 'server', mode: 'form' }, - batch - ]) - ).at(-1) - ).toBe('PostToolBatch:waiting') - expect( - ( - await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { hook_event_name: 'Stop' }, - batch - ]) - ).at(-1) - ).toBe('PostToolBatch:done') - }) - it('keeps a wait whose request it cannot name, and an unanswered question with its card', async () => { const batch = { hook_event_name: 'PostToolBatch', @@ -549,37 +269,6 @@ describe('Claude permission wait that Claude denies itself', () => { expect(row?.interactivePrompt).toContain('Which file?') }) - it('keeps an MCP dialog that follows a permission when the permission ends', async () => { - const command = { command: 'touch /private/tmp/orca-permcap-dialog' } - const states = await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: command, - tool_use_id: 'toolu-touch' - }, - { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: command }, - { hook_event_name: 'Elicitation', mcp_server_name: 'server', mode: 'form' }, - { - hook_event_name: 'PostToolBatch', - tool_calls: [{ tool_name: 'Bash', tool_input: command, tool_use_id: 'toolu-touch' }] - } - ]) - expect(states.at(-1)).toBe('PostToolBatch:waiting') - }) - - it("does not reopen a finished turn when a subagent's batch arrives late", async () => { - const childBatchEnd = capture.childDenied.find( - (event) => event.hook_event_name === 'PostToolBatch' && 'agent_id' in event - ) - const untilStop = capture.childDenied.slice( - 0, - capture.childDenied.findIndex((event) => event.hook_event_name === 'Stop') + 1 - ) - expect((await replay([...untilStop, childBatchEnd!])).at(-1)).toBe('PostToolBatch:done') - }) - it('settles the first request of a batch longer than the replay history bound', async () => { const command = { command: 'touch /private/tmp/orca-permcap-long' } const siblings = Array.from({ length: 20 }, (_, index) => ({ @@ -608,7 +297,6 @@ describe('Claude permission wait that Claude denies itself', () => { }) describe('with a wait Orca cannot identify', () => { - const childA = { agent_id: 'agent-a', agent_type: 'general-purpose' } const childB = { agent_id: 'agent-b', agent_type: 'general-purpose' } const commandB = { command: 'touch /private/tmp/orca-permcap-b' } const childBDenial = [ @@ -654,67 +342,6 @@ describe('Claude permission wait that Claude denies itself', () => { expect(states.at(-1)).toBe('PostToolBatch:waiting') }) - it('ends a wait whose call was never seen when the user approves the request', async () => { - const command = { command: 'touch /private/tmp/orca-permcap-unseen' } - const request = { - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: command, - ...childA - } - const states = await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { hook_event_name: 'SubagentStart', ...childA }, - request, - request, - { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: command, - tool_use_id: 'toolu-unseen', - ...childA - } - ]) - expect(states.slice(-2)).toEqual(['PermissionRequest:waiting', 'PostToolUse:working']) - }) - - it("keeps a request whose call was never seen after the same agent's matching tool finishes", async () => { - const readA = { file_path: '/tmp/orca-permcap-a' } - const states = await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { hook_event_name: 'SubagentStart', ...childA }, - { hook_event_name: 'SubagentStart', ...childB }, - { hook_event_name: 'PermissionRequest', tool_name: 'Read', tool_input: readA, ...childA }, - { - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: { file_path: '/tmp/orca-permcap-sibling' }, - tool_use_id: 'toolu-sibling', - ...childA - }, - { - hook_event_name: 'PostToolUse', - tool_name: 'Read', - tool_input: { file_path: '/tmp/orca-permcap-sibling' }, - tool_use_id: 'toolu-sibling', - ...childA - }, - { - hook_event_name: 'PostToolBatch', - tool_calls: [ - { - tool_name: 'Read', - tool_input: { file_path: '/tmp/orca-permcap-sibling' }, - tool_use_id: 'toolu-sibling' - } - ], - ...childA - }, - ...childBDenial - ]) - expect(states.at(-1)).toBe('PostToolBatch:waiting') - }) - const leadCommand = { command: 'touch /private/tmp/orca-permcap-lead' } const leadRequest = [ { @@ -726,21 +353,6 @@ describe('Claude permission wait that Claude denies itself', () => { { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: leadCommand } ] - it("keeps the lead's open prompt when a task notification arrives before a subagent's request ends", async () => { - const states = await replay([ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { hook_event_name: 'SubagentStart', ...childB }, - ...leadRequest, - { - hook_event_name: 'UserPromptSubmit', - prompt: - '\na1\ncompleted\n' - }, - ...childBDenial - ]) - expect(states.at(-1)).toBe('PostToolBatch:waiting') - }) - it("keeps the lead's open prompt when another agent reports a result under the same id", async () => { const childC = { agent_id: 'agent-c', agent_type: 'general-purpose' } const states = await replay([ diff --git a/src/main/automations/runtime-automation-service.test.ts b/src/main/automations/runtime-automation-service.test.ts deleted file mode 100644 index 5c66e1a1f25..00000000000 --- a/src/main/automations/runtime-automation-service.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as AgentLauncher from './automation-chat-agent-launch' -import type { HeadlessAutomationDispatcher } from './headless-dispatch' -import type { AutomationRunTerminalObserver } from './run-completion-watcher' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { Store } from '../persistence/loading-store/store' -import { buildProfileStateCutoverFixture } from '../persistence/profile-state-cutover-fixture' -import { automationRunOperationId } from './automation-run-operation' - -type Options = { - headlessDispatcher?: HeadlessAutomationDispatcher - chatDispatcher?: HeadlessAutomationDispatcher - terminalObserver?: AutomationRunTerminalObserver -} -const captured = vi.hoisted(() => { - const options: Options = {} - return { - options, - launch: vi.fn(), - observeChat: vi.fn(), - markDispatchResult: vi.fn() - } -}) -const { deliverTerminalAgentLaunchPrompt } = vi.hoisted(() => ({ - deliverTerminalAgentLaunchPrompt: vi.fn(async () => true) -})) -vi.mock('../runtime/rpc/methods/agent-launch-terminal-prompt', () => ({ - deliverTerminalAgentLaunchPrompt -})) - -vi.mock('./service', () => ({ - AutomationService: class { - start(): void {} - stop(): void {} - markDispatchResult = captured.markDispatchResult - constructor(_store: unknown, opts: Options) { - captured.options = opts - } - } -})) -vi.mock('./automation-chat-agent-launch', async (original) => ({ - ...(await original()), - launchAutomationChatAgent: captured.launch -})) -vi.mock('./runtime-chat-run-observer', () => ({ observeAutomationChatRun: captured.observeChat })) -import { createRuntimeAutomationService } from './runtime-automation-service' -const state = buildProfileStateCutoverFixture('/fixture') -const run = { - ...state.automationRuns[0], - createdAt: 1_700_000_000_000, - status: 'dispatching' as const, - chatSessionId: null -} -function fixture(headless: boolean, definitionSurface: 'chat' | 'terminal' = 'chat') { - const rows = new Map() - const records = { - getOperationRow: (_caller: string, id: string) => rows.get(id), - getConversation: () => ({ - sessionId: 'claude_chat', - provider: 'claude', - location: { workspaceId: 'wt-1' } - }) - } - const runtime = { - setAutomationService: vi.fn(), - notifyAutomationsChanged: vi.fn(), - openAgentSessionRecordStore: async () => records, - openedAgentSessionRecordStore: () => records, - getTerminalHandleForPaneKey: vi.fn((_pane: string): string | null => null), - getTerminalPtyIdForHandle: vi.fn((_handle: string): string | null => 'pty-1'), - getTerminalPaneKey: vi.fn( - () => '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:3f2504e0-4f89-41d3-9a0c-0305e82c3301' - ), - waitForTerminal: vi.fn(async () => ({ state: 'done' })), - getAgentStatusRowsForPane: vi.fn(() => []) - } - createRuntimeAutomationService({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: construction and recovery use only these store boundaries. - store: { - listAutomationRuns: () => [], - listAutomations: () => [ - { id: run.automationId, agentId: 'claude', surface: definitionSurface } - ] - } as unknown as Store, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launching and chat observation are mocked; the runtime implements the recovery boundaries. - runtime: runtime as unknown as OrcaRuntimeService, - headless - }) - return { runtime, rows } -} -beforeEach(() => { - vi.clearAllMocks() - captured.markDispatchResult.mockImplementation(async (result) => ({ ...run, ...result })) - captured.observeChat.mockResolvedValue({ status: 'completed' }) -}) -describe('host-owned automation execution', () => { - it.each([false, true])( - 'uses the same Chat dispatcher on desktop/headless=%s without supplying a desktop Terminal dispatcher', - async (headless) => { - fixture(headless) - captured.launch.mockResolvedValue({ - workspaceId: 'wt-1', - terminalSessionId: 'tab-1', - terminalPaneKey: - '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:3f2504e0-4f89-41d3-9a0c-0305e82c3301', - terminalHandle: 'term-1' - }) - expect(Boolean(captured.options.headlessDispatcher)).toBe(headless) - const dispatcher = captured.options.chatDispatcher! - const result = await dispatcher({ - automation: { ...state.automations[0], surface: 'chat' }, - run, - target: { ok: true, cwd: '/fixture', repo: state.repos[0] } - }) - expect(result.completion).toBeUndefined() - expect(captured.launch).toHaveBeenCalledOnce() - expect( - captured.options.terminalObserver!.resolveRunTerminal({ - ...run, - status: 'dispatched', - terminalPaneKey: - '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:3f2504e0-4f89-41d3-9a0c-0305e82c3301' - }) - ).toBe('term-1') - } - ) - it.each(['fresh-launch', 'reuse-send'] as const)( - 'recovers a %s accepted chat before the automation run identity was saved', - async (purpose) => { - const { rows } = fixture(false, purpose === 'fresh-launch' ? 'terminal' : 'chat') - rows.set(automationRunOperationId(run, purpose), { - outcome: { - status: 'succeeded', - sessionId: 'claude_chat', - ...(purpose === 'fresh-launch' - ? { - launch: { - worktreeId: 'wt-1', - outcome: { kind: 'structured', sessionId: 'claude_chat', handle: 'chat-tab' }, - receipt: { - mode: 'structured', - preferred: 'structured', - reason: 'requested', - detail: 'Chat' - }, - prompt: { delivery: 'submit', outcome: 'journaled', messageId: 'opening' } - } - } - : {}) - } - }) - const observer = captured.options.terminalObserver! - const handle = observer.resolveRunTerminal(run)! - expect( - await observer.observeCompletion(handle, { run, signal: new AbortController().signal }) - ).toEqual({ status: 'completed' }) - expect(captured.markDispatchResult).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKind: 'chat', - chatSessionId: 'claude_chat', - terminalPaneKey: null, - terminalSessionId: null, - terminalPtyId: null - }) - ) - expect(captured.observeChat).toHaveBeenCalledOnce() - expect(captured.launch).not.toHaveBeenCalled() - } - ) - it('recovers an accepted Chat request that actually fell back to Terminal without relaunch', async () => { - const { rows, runtime } = fixture(false, 'terminal') - rows.set(automationRunOperationId(run, 'fresh-launch'), { - outcome: { - status: 'succeeded', - launch: { - worktreeId: 'wt-1', - outcome: { - kind: 'terminal', - handle: 'term-fallback', - paneKey: 'tab:11111111-1111-4111-8111-111111111111' - }, - receipt: { - mode: 'terminal', - preferred: 'structured', - reason: 'unsupported', - detail: 'Fallback' - }, - prompt: { delivery: 'submit', outcome: 'handed-to-terminal' } - } - } - }) - runtime.waitForTerminal.mockRejectedValueOnce(new Error('ordinary-terminal-observer')) - const observer = captured.options.terminalObserver! - await expect( - observer.observeCompletion(observer.resolveRunTerminal(run)!, { - run, - signal: new AbortController().signal - }) - ).rejects.toThrow('ordinary-terminal-observer') - expect(captured.markDispatchResult).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'dispatched', - sessionKind: 'terminal', - terminalPtyId: 'pty-1' - }) - ) - expect(runtime.waitForTerminal).toHaveBeenCalledWith('term-fallback', expect.anything()) - expect(captured.observeChat).not.toHaveBeenCalled() - expect(captured.launch).not.toHaveBeenCalled() - }) - it.each(['chat', 'terminal'] as const)( - 'recovers a published failed %s receipt without accepting or sending it', - async (kind) => { - const { rows, runtime } = fixture(false) - rows.set(automationRunOperationId(run, 'fresh-launch'), { - outcome: { - status: 'succeeded', - launch: { - worktreeId: 'actually-published', - outcome: - kind === 'chat' - ? { kind: 'structured', sessionId: 'claude_published', handle: 'chat-tab' } - : { - kind: 'terminal', - handle: 'term-published', - paneKey: 'tab:11111111-1111-4111-8111-111111111111' - }, - receipt: { - mode: kind === 'chat' ? 'structured' : 'terminal', - preferred: 'structured', - reason: 'requested', - detail: 'Published' - }, - prompt: { delivery: 'submit', outcome: 'unconfirmed' } - } - } - }) - const observer = captured.options.terminalObserver! - expect( - await observer.observeCompletion(observer.resolveRunTerminal(run)!, { - run, - signal: new AbortController().signal - }) - ).toMatchObject({ status: 'dispatch_failed' }) - expect(captured.markDispatchResult).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'actually-published', - status: 'dispatch_failed', - sessionKind: kind, - ...(kind === 'chat' - ? { chatSessionId: 'claude_published', terminalPaneKey: null } - : { - terminalSessionId: 'tab', - terminalPaneKey: 'tab:11111111-1111-4111-8111-111111111111' - }) - }) - ) - expect(runtime.waitForTerminal).not.toHaveBeenCalled() - expect(captured.observeChat).not.toHaveBeenCalled() - expect(captured.launch).not.toHaveBeenCalled() - } - ) - it('refuses an ambiguous crash without resending or marking success', async () => { - const { rows } = fixture(false) - rows.set(automationRunOperationId(run, 'fresh-launch'), { outcome: { status: 'unknown' } }) - const observer = captured.options.terminalObserver! - expect( - await observer.observeCompletion(observer.resolveRunTerminal(run)!, { - run, - signal: new AbortController().signal - }) - ).toMatchObject({ status: 'dispatch_failed', error: expect.stringContaining('Claude') }) - expect(captured.launch).not.toHaveBeenCalled() - }) -}) - -function capturedOption(key: K): NonNullable { - const value = captured.options[key] - if (value === undefined) { - throw new Error(`the service was constructed without ${key}`) - } - return value -} - -describe('headless automation dispatch', () => { - it('hands the launched run to its watcher instead of awaiting one tui-idle wait itself', async () => { - const repo = { - id: 'repo-1', - path: '/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1 - } - const managedWorktree = { id: 'wt-1', displayName: 'repo', hostId: 'local' as const } - const runtime = { - setAutomationService: vi.fn(), - notifyAutomationsChanged: vi.fn(), - launchAgentTerminal: vi.fn(async () => ({ - handle: 'terminal-1', - tabId: 'tab-1', - paneKey: 'tab-1:pane-1', - ptyId: 'pty-1', - worktreeId: 'wt-1' - })), - resolveManagedTerminalWorkspaceTarget: vi.fn(async () => ({ - managedWorktree, - scope: { folderWorkspace: null } - })), - waitForTerminal: vi.fn(), - getTerminalHandleForPaneKey: vi.fn(() => null), - getAgentStatusRowsForPane: vi.fn(() => []) - } - createRuntimeAutomationService({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked service never reads the store; the dispatcher reads only past runs. - store: { listAutomationRuns: () => [] } as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only the members faked above. - runtime: runtime as never, - headless: true - }) - const dispatcher = capturedOption('headlessDispatcher') - const launch = await dispatcher({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a reuse-workspace automation; only these fields are read. - automation: { - workspaceMode: 'existing', - workspaceId: 'wt-1', - agentId: 'goose' - } as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the title and trigger are read. - run: { title: 'Nightly', trigger: 'scheduled' } as never, - target: { ok: true, cwd: '/repo', repo } - }) - - expect(launch.completion).toBeUndefined() - expect(launch.terminalPaneKey).toBe('tab-1:pane-1') - expect(runtime.resolveManagedTerminalWorkspaceTarget).toHaveBeenCalledWith( - 'id:wt-1', - 'local', - false - ) - expect(runtime.launchAgentTerminal).toHaveBeenCalledWith( - 'id:wt-1', - { - agent: 'goose', - prompt: '', - title: 'Nightly', - suppressStartupUpdatePrompt: true - }, - { worktree: managedWorktree, repo, folderWorkspace: undefined } - ) - expect(runtime.waitForTerminal).not.toHaveBeenCalled() - const observer = capturedOption('terminalObserver') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolution reads only the pane key. - expect(observer.resolveRunTerminal({ terminalPaneKey: 'tab-1:pane-1' } as never)).toBe( - 'terminal-1' - ) - }) -}) diff --git a/src/main/automations/runtime-chat-run-observer.test.ts b/src/main/automations/runtime-chat-run-observer.test.ts index ff7e90f8f18..60ba60a1313 100644 --- a/src/main/automations/runtime-chat-run-observer.test.ts +++ b/src/main/automations/runtime-chat-run-observer.test.ts @@ -69,16 +69,6 @@ function turn( } } } -function answer(itemId: string, turnItemId: string, text: string): AgentJournalRenderItem { - return { - itemId, - revision: 1, - sequence: 2, - observedAt: 2, - turnScope: { kind: 'turn', turnItemId }, - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } - } -} function snapshot(items: AgentJournalRenderItem[] = []): AgentJournalSnapshot { return { sessionId: 'claude_chat', @@ -95,89 +85,14 @@ afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) -describe('exact automation chat turn', () => { - it('waits for its own prompt even when a later human turn completed', () => { - expect( - chatAutomationRunObservation( - snapshot([turn('own', 'provider-user', 'running'), turn('human', 'other')]), - 'prompt', - 'Claude' - ) - ).toBeNull() - const missingRoot = snapshot([turn('human', 'other')]) - const roots = vi.spyOn(missingRoot.items, 'find') - expect(chatAutomationRunObservation(missingRoot, 'prompt', 'Claude')).toBeNull() - expect(roots).toHaveBeenCalledOnce() - missingRoot.submissions = [] - roots.mockClear() - expect(chatAutomationRunObservation(missingRoot, 'prompt', 'Claude')).toBeNull() - expect(roots).not.toHaveBeenCalled() - }) - it('matches the Orca opener alias and snapshots only the accepted root turn', () => { - const view = snapshot([ - turn('own', agentJournalSubmissionKey('prompt')), - answer('a', 'own', 'Automation output'), - answer('b', 'human', 'Private later output'), - { ...answer('c', 'own', 'Child output'), agentId: 'child' } - ]) - let outputRows = 0 - const items = view.items[Symbol.iterator].bind(view.items) - vi.spyOn(view.items, Symbol.iterator).mockImplementation(function* () { - for (const item of items()) { - outputRows++ - yield item - } - return undefined - }) - const result = chatAutomationRunObservation(view, 'prompt', 'Codex') - expect(outputRows).toBe(4) - expect(result).toMatchObject({ - status: 'completed', - error: null, - outputSnapshot: { content: 'Automation output' } - }) - }) - it('accepts explicit start proof and never claims a steered or unlinked turn', () => { - const view = snapshot([turn('own', 'different')]) - view.submissions = [{ ...submission, answeredInTurn: { turnItemId: 'own', via: 'steer' } }] - expect(chatAutomationRunObservation(view, 'prompt', 'Codex')).toBeNull() - view.submissions = [{ ...submission, answeredInTurn: { turnItemId: 'own', via: 'start' } }] - expect(chatAutomationRunObservation(view, 'prompt', 'Codex')?.status).toBe('completed') - }) - it('reports Stop as Interrupted and unknown or refused prompts as failures', () => { - expect( - chatAutomationRunObservation( - snapshot([turn('own', 'provider-user', 'interrupted')]), - 'prompt', - 'Claude' - ) - ).toMatchObject({ status: 'dispatch_failed', error: expect.stringContaining('Interrupted') }) - const view = snapshot() - view.submissions = [{ ...submission, dispatchState: 'unknown' }] - expect(chatAutomationRunObservation(view, 'prompt', 'Claude')).toMatchObject({ - status: 'dispatch_failed', - error: expect.stringContaining('Claude') - }) - }) - it('does not infer success from completed without an outcome and bounds output', () => { - const own = turn('own', 'provider-user') - own.body = { kind: 'turn', turnId: 'own', userItemId: 'provider-user', state: 'completed' } - const result = chatAutomationRunObservation( - snapshot([own, answer('a', 'own', 'x'.repeat(300_000))]), - 'prompt', - 'Claude' - ) - expect(result?.status).toBe('dispatch_failed') - expect(result?.outputSnapshot?.content.length).toBeLessThanOrEqual(256 * 1024) - expect(result?.outputSnapshot?.truncated).toBe(true) - }) -}) describe('restart observation and disposal', () => { function fixture() { const run = { ...buildProfileStateCutoverFixture('/fixture').automationRuns[0], + // ast-grep-ignore: test-no-wall-clock -- Run timestamps must match the live host expiry clock. createdAt: Date.now(), + // ast-grep-ignore: test-no-wall-clock -- Run timestamps must match the live host expiry clock. dispatchedAt: Date.now(), sessionKind: 'chat' as const, chatSessionId: 'claude_chat' @@ -254,65 +169,6 @@ describe('restart observation and disposal', () => { expect(mocks.host.journalCurrentRead).toHaveBeenCalledTimes(3) expect(release).toHaveBeenCalledOnce() }) - it('ends read errors, listener errors, host-end and the six-hour deadline exactly once', async () => { - vi.useFakeTimers() - const { run, runtime, view } = fixture() - for (const mode of ['read', 'listener', 'end', 'deadline'] as const) { - const release = vi.fn() - let observer: - | { next: (hint: JournalPublication) => void; error: (error: unknown) => void } - | undefined - mocks.host.observeConversationPublications.mockImplementation((_id, next) => { - observer = next - next.next({ kind: 'reset', source: {}, journal: null }) - return release - }) - mocks.host.journalCurrentRead.mockImplementation(async () => { - if (mode === 'read') { - throw new Error('unreadable') - } - return view - }) - const pending = observeAutomationChatRun(runtime, run, new AbortController().signal) - const result = pending.then( - (value) => value, - (error) => error - ) - await vi.advanceTimersByTimeAsync(0) - if (mode === 'listener') { - observer!.error(new Error('consumer')) - } - if (mode === 'end') { - observer!.next({ kind: 'end', source: null, journal: null }) - } - if (mode === 'deadline') { - await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000) - } - expect(await result).toMatchObject( - mode === 'deadline' - ? { status: 'dispatch_failed' } - : { - message: - mode === 'read' - ? 'unreadable' - : mode === 'listener' - ? 'consumer' - : 'Chat session host stopped' - } - ) - observer!.next({ kind: 'reset', source: {}, journal: null }) - expect(release).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - } - }) - it('fails promptly when no exact accepted receipt survives', async () => { - const { run, runtime, records } = fixture() - records.getOperationRow.mockReturnValue(undefined) - expect( - await observeAutomationChatRun(runtime, run, new AbortController().signal) - ).toMatchObject({ status: 'dispatch_failed', error: expect.stringContaining('confirm') }) - expect(mocks.host.observeConversationPublications).not.toHaveBeenCalled() - }) }) // The provider is scripted; queue ownership, journal, catch-up and publication are real. @@ -356,7 +212,9 @@ describe('real host queue publication and operation bounds', () => { await sink.written?.() const run = { ...buildProfileStateCutoverFixture('/fixture').automationRuns[0], + // ast-grep-ignore: test-no-wall-clock -- Run timestamps must match the live host expiry clock. createdAt: Date.now(), + // ast-grep-ignore: test-no-wall-clock -- Run timestamps must match the live host expiry clock. dispatchedAt: Date.now(), sessionKind: 'chat' as const, chatSessionId: HOST_TEST_SESSION @@ -621,7 +479,9 @@ describe('real host queue publication and operation bounds', () => { const working = await rig.workingSend() const run = { ...buildProfileStateCutoverFixture('/fixture').automationRuns[0], + // ast-grep-ignore: test-no-wall-clock -- Run timestamps must match the live host expiry clock. createdAt: Date.now(), + // ast-grep-ignore: test-no-wall-clock -- Run timestamps must match the live host expiry clock. dispatchedAt: Date.now(), chatSessionId: HOST_TEST_SESSION } diff --git a/src/main/automations/runtime-terminal-run-observer-evidence.test.ts b/src/main/automations/runtime-terminal-run-observer-evidence.test.ts index 710f461e235..1c6827cfe39 100644 --- a/src/main/automations/runtime-terminal-run-observer-evidence.test.ts +++ b/src/main/automations/runtime-terminal-run-observer-evidence.test.ts @@ -119,52 +119,7 @@ afterEach(() => { }) describe('observing a run with agent evidence', () => { - it.each([ - ['bash', 'bash: goose: command not found'], - ['zsh', 'zsh: command not found: goose'], - ['dash', 'sh: 1: goose: not found'], - ['fish', 'fish: Unknown command: goose'], - ['PowerShell', "goose: The term 'goose' is not recognized as the name of a cmdlet"] - ])('fails, never completes, a run whose agent %s cannot find', async (_shell, refusal) => { - const run = observe(createPane({ idle: true, tail: ['$ goose run', refusal, '$'] })) - await run.promise - expect(run.settled).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'dispatch_failed', - error: expect.stringContaining(refusal) - }) - ) - }) - - it('completes once the agent itself reported for the run pane', async () => { - const pane = createPane({ idle: false, tail: ['working'] }) - const run = observe(pane) - await vi.advanceTimersByTimeAsync(1_000) - pane.report() - pane.becomeIdle() - await run.promise - expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })) - }) - // Why: headless runs have no renderer writer, so the observer must fail a cut-off turn (STA-12638). - it.each([ - { name: 'on its idle edge', becomeIdle: true }, - { name: 'from a held done on a pane that never reads idle', becomeIdle: false } - ])('fails a run whose agent reported its turn interrupted, $name', async ({ becomeIdle }) => { - const pane = createPane({ idle: false, tail: ['working'] }) - const run = observe(pane) - await vi.advanceTimersByTimeAsync(1_000) - pane.report({ interrupted: true }) - if (becomeIdle) { - pane.becomeIdle() - } else { - await vi.advanceTimersByTimeAsync(90_000) - } - await run.promise - expect(run.settled).toHaveBeenCalledWith( - expect.objectContaining({ status: 'dispatch_failed', errorCode: 'agentInterrupted' }) - ) - }) it('keeps a held interrupted verdict when its row is cleared during the screen read', async () => { const pane = createPane({ idle: false, tail: ['working'], clearRowsOnRead: true }) @@ -178,68 +133,6 @@ describe('observing a run with agent evidence', () => { ) }) - it("waits out a reused pane's previous done until this run's turn is done", async () => { - const pane = createPane({ idle: true, tail: ['previous summary', '❯'] }) - // A reconnect replay restamps receivedAt; the done itself still predates the run. - pane.report({ stateStartedAt: Date.now() - 60_000 }) - const run = observe(pane) - await vi.advanceTimersByTimeAsync(5_000) - expect(run.settled).not.toHaveBeenCalled() - - pane.becomeBusy() - pane.setTail(['previous summary', '❯ nightly prompt', 'working']) - pane.report({ state: 'working' }) - await vi.advanceTimersByTimeAsync(5_000) - expect(run.settled).not.toHaveBeenCalled() - - pane.report() - pane.becomeIdle() - await run.promise - expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })) - }) - - it.each([ - ['a working row', { state: 'working' as const }], - ['a connect-time done', { sessionBoundary: true }] - ])('does not read %s on an idle pane as the run finishing', async (_label, row) => { - const pane = createPane({ idle: true, tail: ['❯'] }) - pane.report(row) - const run = observe(pane) - await vi.advanceTimersByTimeAsync(AGENT_START_GRACE_MS - 1_000) - expect(run.settled).not.toHaveBeenCalled() - }) - - it('does not take the done of a run that finished while this one waited in its precheck', async () => { - const startedAt = Date.now() - await vi.advanceTimersByTimeAsync(60_000) - const pane = createPane({ idle: true, tail: ['other run summary', '❯'] }) - pane.report() - await vi.advanceTimersByTimeAsync(30_000) - const run = observe(pane, undefined, { startedAt, dispatchedAt: Date.now() }) - await vi.advanceTimersByTimeAsync(5_000) - expect(run.settled).not.toHaveBeenCalled() - - pane.becomeBusy() - pane.setTail(['other run summary', '❯ nightly prompt', 'working']) - pane.report({ state: 'working' }) - await vi.advanceTimersByTimeAsync(5_000) - pane.report() - pane.becomeIdle() - await run.promise - expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })) - }) - - it('completes a turn that ended before the launch returned', async () => { - const dispatchedAt = Date.now() - await vi.advanceTimersByTimeAsync(1_000) - const pane = createPane({ idle: true, tail: ['summary written', '❯'] }) - pane.report() - await vi.advanceTimersByTimeAsync(1_000) - const run = observe(pane, undefined, { startedAt: dispatchedAt, dispatchedAt }) - await run.promise - expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })) - }) - it('never completes a reused pane on its old done after a long precheck, past any grace', async () => { const pane = createPane({ idle: true, tail: ['previous summary', '❯'] }) pane.report({ stateStartedAt: Date.now() - 600_000 }) @@ -257,37 +150,6 @@ describe('observing a run with agent evidence', () => { ) }) - it("measures a hookless agent's start window from dispatch, not from before its precheck", async () => { - const pane = createPane({ idle: true, tail: ['summary written', '$'] }) - const run = observe(pane, undefined, { - startedAt: Date.now() - 180_000, - dispatchedAt: Date.now() - }) - await vi.advanceTimersByTimeAsync(10_000) - expect(run.settled).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(AGENT_START_GRACE_MS) - await run.promise - expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })) - }) - - it("never reads an agent's own tool output as its command missing", async () => { - const tail = ['running tests', 'bash: pytest: command not found', 'fell back to unittest', '$'] - const run = observe(createPane({ idle: true, tail })) - await vi.advanceTimersByTimeAsync(AGENT_START_GRACE_MS + 1_000) - await run.promise - expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })) - }) - - it('completes an agent that exited before the window once the window passes, not at a wait timeout', async () => { - const pane = createPane({ idle: true, tail: ['stub done', '$'], idleEdgeOnly: true }) - const run = observe(pane) - await vi.advanceTimersByTimeAsync(AGENT_START_GRACE_MS - 1_000) - expect(run.settled).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(2_000) - expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })) - expect(run.failed).not.toHaveBeenCalled() - }) - it('keeps watching an agent past a tui-idle wait timeout and completes it later', async () => { const pane = createPane({ idle: false, tail: ['working'] }) const run = observe(pane) diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index 11c5ef9f2b7..caa0c1fa54a 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -132,31 +132,6 @@ describe('AutomationService prechecks', () => { expect(runAutomationPrecheckMock).not.toHaveBeenCalled() }) - it('does not run prechecks for manual dispatches', async () => { - vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) - const store = await createStore() - store.addRepo(makeRepo()) - const automation = store.createAutomation({ - name: 'Manual check', - prompt: 'Check the repo', - precheck: { - command: 'test -f ready', - timeoutSeconds: 30 - }, - agentId: 'claude', - projectId: 'r1', - workspaceMode: 'new_per_run', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-14T00:00:00Z').getTime() - }) - const run = store.createAutomationRun(automation, Date.now(), 'manual') - const service = new AutomationService(store, { tickMs: 60_000 }) - - await expect(service.runPrecheck(automation.id, run.id)).resolves.toBeNull() - expect(runAutomationPrecheckMock).not.toHaveBeenCalled() - }) - it('honors scheduled prechecks before headless dispatch', async () => { vi.setSystemTime(new Date('2026-05-12T08:59:00Z')) const store = await createStore() diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 72e98e01dff..1248e5b9d67 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -53,326 +53,6 @@ describe('AutomationService', () => { rmSync(testState.dir, { recursive: true, force: true }) }) - it('dispatches an enabled automation when its next run is due', async () => { - vi.setSystemTime(new Date('2026-05-13T08:59:00')) - const store = await createStore() - store.addRepo(makeRepo()) - const automation = store.createAutomation({ - name: 'Morning check', - prompt: 'Check the repo', - agentId: 'claude', - projectId: 'r1', - workspaceMode: 'existing', - workspaceId: 'wt1', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-12T00:00:00').getTime() - }) - - vi.setSystemTime(new Date('2026-05-13T09:01:00')) - const send = vi.fn() - const service = new AutomationService(store, { tickMs: 60_000 }) - service.setWebContents({ - isDestroyed: () => false, - send - } as never) - - service.start() - service.setRendererReady() - await vi.waitFor(() => - expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.any(Object)) - ) - service.stop() - - const [, payload] = send.mock.calls[0] - expect(payload.automation.id).toBe(automation.id) - expect(payload.run.scheduledFor).toBe(new Date('2026-05-13T09:00:00').getTime()) - expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatching') - expect(store.listAutomations().find((entry) => entry.id === automation.id)?.nextRunAt).toBe( - new Date('2026-05-14T09:00:00').getTime() - ) - }) - - it('returns the persisted status for manual runs after dispatch is requested', async () => { - vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) - const store = await createStore() - store.addRepo(makeRepo()) - const automation = store.createAutomation({ - name: 'Manual check', - prompt: 'Check the repo', - agentId: 'claude', - projectId: 'r1', - workspaceMode: 'existing', - workspaceId: 'wt1', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-14T00:00:00Z').getTime() - }) - const send = vi.fn() - const service = new AutomationService(store, { tickMs: 60_000 }) - service.setWebContents({ - isDestroyed: () => false, - send - } as never) - service.setRendererReady() - - const run = await service.runNow(automation.id) - - expect(run.status).toBe('dispatching') - expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatching') - expect(send).toHaveBeenCalledWith( - 'automations:dispatchRequested', - expect.objectContaining({ - run: expect.objectContaining({ id: run.id, status: 'dispatching' }) - }) - ) - }) - - it('skips dispatch when the selected project host setup is gone', async () => { - vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) - const store = await createStore() - store.addRepo(makeRepo()) - const automation = store.createAutomation({ - name: 'Manual check', - prompt: 'Check the repo', - agentId: 'claude', - projectId: 'r1', - workspaceMode: 'new_per_run', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-14T00:00:00Z').getTime() - }) - mutateDataFile((state) => { - state.automations[0].runContext = { - kind: 'workspace-run', - projectId: 'project-1', - hostId: 'local', - projectHostSetupId: 'missing-setup', - repoId: 'r1', - path: '/repo' - } - }) - const reloaded = await createStore() - const send = vi.fn() - const service = new AutomationService(reloaded, { tickMs: 60_000 }) - service.setWebContents({ - isDestroyed: () => false, - send - } as never) - service.setRendererReady() - - const run = await service.runNow(automation.id) - - expect(run.status).toBe('skipped_unavailable') - expect(run.error).toBe('Project is not set up on the selected automation host anymore.') - expect(send).not.toHaveBeenCalled() - }) - - it('skips dispatch when the saved project host setup path is stale', async () => { - vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) - const store = await createStore() - store.addRepo(makeRepo({ path: '/repo/current' })) - const setup = store.getProjectHostSetups()[0]! - const automation = store.createAutomation({ - name: 'Manual check', - prompt: 'Check the repo', - agentId: 'claude', - projectId: 'r1', - workspaceMode: 'new_per_run', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-14T00:00:00Z').getTime() - }) - mutateDataFile((state) => { - state.automations[0].runContext = { - kind: 'workspace-run', - projectId: setup.projectId, - hostId: setup.hostId, - projectHostSetupId: setup.id, - repoId: setup.repoId, - path: '/repo/old' - } - }) - const reloaded = await createStore() - const send = vi.fn() - const service = new AutomationService(reloaded, { tickMs: 60_000 }) - service.setWebContents({ - isDestroyed: () => false, - send - } as never) - service.setRendererReady() - - const run = await service.runNow(automation.id) - - expect(run.status).toBe('skipped_unavailable') - expect(run.error).toBe('Project path for the selected automation host has changed.') - expect(send).not.toHaveBeenCalled() - }) - - it('skips runtime-owned automations before desktop renderer dispatch', async () => { - vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) - const store = await createStore() - const runtimeHostId = toRuntimeExecutionHostId('gpu-server') - const repo = makeRepo({ executionHostId: runtimeHostId }) - store.addRepo(repo) - const setup = store.getProjectHostSetups()[0]! - const automation = store.createAutomation( - { - name: 'Remote check', - prompt: 'Check the remote repo', - agentId: 'claude', - projectId: 'r1', - runContext: { - kind: 'workspace-run', - projectId: setup.projectId, - hostId: runtimeHostId, - projectHostSetupId: setup.id, - repoId: setup.repoId, - path: setup.path - }, - workspaceMode: 'new_per_run', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-14T00:00:00Z').getTime() - }, - { resolvedRepo: repo } - ) - const send = vi.fn() - const service = new AutomationService(store, { tickMs: 60_000 }) - service.setWebContents({ - isDestroyed: () => false, - send - } as never) - service.setRendererReady() - - const run = await service.runNow(automation.id) - - expect(run.status).toBe('skipped_unavailable') - expect(run.error).toContain('Remote-server automation scheduling is not available') - expect(send).not.toHaveBeenCalled() - }) - - it('dispatches remote-host scheduled automations when service runs in serve mode', async () => { - vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) - const store = await createStore() - const runtimeHostId = toRuntimeExecutionHostId('gpu-server') - const repo = makeRepo({ executionHostId: runtimeHostId }) - store.addRepo(repo) - const setup = store.getProjectHostSetups()[0]! - const automation = store.createAutomation( - { - name: 'Remote check', - prompt: 'Check the remote repo', - agentId: 'claude', - projectId: 'r1', - runContext: { - kind: 'workspace-run', - projectId: setup.projectId, - hostId: runtimeHostId, - projectHostSetupId: setup.id, - repoId: setup.repoId, - path: setup.path - }, - workspaceMode: 'new_per_run', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-14T00:00:00Z').getTime() - }, - { resolvedRepo: repo } - ) - const send = vi.fn() - const service = new AutomationService(store, { - tickMs: 60_000, - allowRemoteHostScheduling: true - }) - service.setWebContents({ - isDestroyed: () => false, - send - } as never) - service.setRendererReady() - - const run = await service.runNow(automation.id) - - expect(run.status).toBe('dispatching') - expect(send).toHaveBeenCalledWith( - 'automations:dispatchRequested', - expect.objectContaining({ - automation: expect.objectContaining({ schedulerOwner: 'remote_host_service' }), - run: expect.objectContaining({ id: run.id, status: 'dispatching' }) - }) - ) - }) - - it('dispatches remote-host automations headlessly when no renderer is available', async () => { - vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) - const store = await createStore() - const runtimeHostId = toRuntimeExecutionHostId('gpu-server') - const repo = makeRepo({ executionHostId: runtimeHostId }) - store.addRepo(repo) - const setup = store.getProjectHostSetups()[0]! - const automation = store.createAutomation( - { - name: 'Remote check', - prompt: 'Check the remote repo', - agentId: 'claude', - projectId: 'r1', - runContext: { - kind: 'workspace-run', - projectId: setup.projectId, - hostId: runtimeHostId, - projectHostSetupId: setup.id, - repoId: setup.repoId, - path: setup.path - }, - workspaceMode: 'new_per_run', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-14T00:00:00Z').getTime() - }, - { resolvedRepo: repo } - ) - const service = new AutomationService(store, { - tickMs: 60_000, - allowRemoteHostScheduling: true, - headlessDispatcher: vi.fn().mockResolvedValue({ - workspaceId: 'remote-wt-1', - workspaceDisplayName: 'Remote automation', - terminalSessionId: 'remote-tab-1', - terminalPaneKey: 'remote-tab-1:11111111-1111-4111-8111-111111111111', - terminalPtyId: 'remote-pty-1', - completion: Promise.resolve({ - status: 'completed', - outputSnapshot: { - format: 'plain_text', - content: 'Done.', - capturedAt: Date.now(), - truncated: false - }, - error: null - }) - }) - }) - - const run = await service.runNow(automation.id) - - expect(run.status).toBe('dispatched') - expect(run.workspaceId).toBe('remote-wt-1') - expect(run.workspaceDisplayName).toBe('Remote automation') - expect(run.terminalSessionId).toBe('remote-tab-1') - expect(run.terminalPaneKey).toBe('remote-tab-1:11111111-1111-4111-8111-111111111111') - expect(run.terminalPtyId).toBe('remote-pty-1') - await vi.waitFor(() => - expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ - status: 'completed', - workspaceId: 'remote-wt-1', - terminalSessionId: 'remote-tab-1', - terminalPaneKey: 'remote-tab-1:11111111-1111-4111-8111-111111111111', - terminalPtyId: 'remote-pty-1', - outputSnapshot: expect.objectContaining({ content: 'Done.' }) - }) - ) - }) - it('dispatches due scheduled automations headlessly', async () => { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const beforeRunAt = new Date(2026, 4, 13, 8, 59).getTime() @@ -441,132 +121,6 @@ describe('AutomationService', () => { } }) - it('attaches provider usage when a completed run can be attributed', async () => { - vi.setSystemTime(new Date('2026-05-13T10:00:00')) - const store = await createStore() - store.addRepo(makeRepo()) - const automation = store.createAutomation({ - name: 'Costed check', - prompt: 'Check spend', - agentId: 'claude', - projectId: 'r1', - workspaceMode: 'existing', - workspaceId: 'wt1', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-13T00:00:00Z').getTime() - }) - const run = store.createAutomationRun(automation, Date.now(), 'manual') - store.updateAutomationRun({ - runId: run.id, - status: 'dispatched', - workspaceId: 'wt1', - terminalSessionId: 'tab-1', - error: null - }) - const usage = { - status: 'known' as const, - provider: 'claude' as const, - model: 'claude-sonnet-4-6', - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 25, - cacheWriteTokens: 10, - reasoningOutputTokens: null, - totalTokens: 185, - estimatedCostUsd: 0.001, - estimatedCostSource: 'api_equivalent' as const, - providerSessionId: 'provider-session-1', - attribution: 'provider_session_time_window' as const, - collectedAt: Date.now(), - unavailableReason: null, - unavailableMessage: null - } - const getAutomationRunUsage = vi.fn().mockResolvedValue(usage) - const service = new AutomationService(store, { - tickMs: 60_000, - claudeUsage: { getAutomationRunUsage } as never - }) - - const updated = await service.markDispatchResult({ - runId: run.id, - status: 'completed', - workspaceId: 'wt1', - terminalSessionId: 'tab-1', - error: null - }) - - expect(updated.usage).toEqual(usage) - expect(getAutomationRunUsage).toHaveBeenCalledWith({ - worktreeId: 'wt1', - terminalSessionId: 'tab-1', - startedAt: expect.any(Number), - completedAt: expect.any(Number) - }) - }) - - it('does not recollect usage for an already-finalized run', async () => { - vi.setSystemTime(new Date('2026-05-13T10:00:00')) - const store = await createStore() - store.addRepo(makeRepo()) - const automation = store.createAutomation({ - name: 'Costed check', - prompt: 'Check spend', - agentId: 'claude', - projectId: 'r1', - workspaceMode: 'existing', - workspaceId: 'wt1', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-13T00:00:00Z').getTime() - }) - const run = store.createAutomationRun(automation, Date.now(), 'manual') - store.updateAutomationRun({ - runId: run.id, - status: 'dispatched', - workspaceId: 'wt1', - terminalSessionId: 'tab-1', - error: null - }) - const usage = { - status: 'known' as const, - provider: 'claude' as const, - model: 'claude-sonnet-4-6', - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 25, - cacheWriteTokens: 10, - reasoningOutputTokens: null, - totalTokens: 185, - estimatedCostUsd: 0.001, - estimatedCostSource: 'api_equivalent' as const, - providerSessionId: 'provider-session-1', - attribution: 'provider_session_time_window' as const, - collectedAt: Date.now(), - unavailableReason: null, - unavailableMessage: null - } - const getAutomationRunUsage = vi.fn().mockResolvedValue(usage) - const service = new AutomationService(store, { - tickMs: 60_000, - claudeUsage: { getAutomationRunUsage } as never - }) - const result = { - runId: run.id, - status: 'completed' as const, - workspaceId: 'wt1', - terminalSessionId: 'tab-1', - error: null - } - - const first = await service.markDispatchResult(result) - const second = await service.markDispatchResult(result) - - expect(first.usage).toEqual(usage) - expect(second.usage).toEqual(usage) - expect(getAutomationRunUsage).toHaveBeenCalledTimes(1) - }) - it('does not throw when retention evicts the run during usage collection', async () => { vi.setSystemTime(new Date('2026-05-13T10:00:00')) const store = await createStore() @@ -621,43 +175,6 @@ describe('AutomationService', () => { expect(store.listAutomationRuns(automation.id).some((entry) => entry.id === run.id)).toBe(false) }) - it('records unsupported usage cleanly for completed agents without local usage stores', async () => { - vi.setSystemTime(new Date('2026-05-13T10:00:00')) - const store = await createStore() - store.addRepo(makeRepo()) - const automation = store.createAutomation({ - name: 'Gemini check', - prompt: 'Check spend', - agentId: 'gemini', - projectId: 'r1', - workspaceMode: 'existing', - workspaceId: 'wt1', - timezone: 'UTC', - rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', - dtstart: new Date('2026-05-13T00:00:00Z').getTime() - }) - const run = store.createAutomationRun(automation, Date.now(), 'manual') - store.updateAutomationRun({ - runId: run.id, - status: 'dispatched', - workspaceId: 'wt1', - terminalSessionId: 'tab-1', - error: null - }) - const service = new AutomationService(store, { tickMs: 60_000 }) - - const updated = await service.markDispatchResult({ - runId: run.id, - status: 'completed', - workspaceId: 'wt1', - terminalSessionId: 'tab-1', - error: null - }) - - expect(updated.usage?.status).toBe('unavailable') - expect(updated.usage?.unavailableReason).toBe('provider_unsupported') - }) - // Name sorting evaluates the poison record before the healthy one. it('keeps evaluating later due automations after an unreadable schedule throws', async () => { vi.setSystemTime(new Date('2026-05-13T08:59:00')) diff --git a/src/main/azure-devops/client.test.ts b/src/main/azure-devops/client.test.ts deleted file mode 100644 index 2f3205f211d..00000000000 --- a/src/main/azure-devops/client.test.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' -import { - getAzureDevOpsAuthStatus, - getAzureDevOpsPullRequestForBranch, - getAzureDevOpsPullRequestForBranchOrThrow -} from './client' -import { _resetAzureDevOpsApiVersionCache } from './azure-devops-api-request' -import { _resetAzureDevOpsRepoRefCache } from './repository-ref' -import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' - -const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal>()), - gitExecFileAsync: gitExecFileAsyncMock -})) - -function primeGitExecWithDefaultBranch(defaultRef = 'refs/remotes/origin/main'): void { - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'https://dev.azure.com/acme/Project/_git/repo\n', stderr: '' } - } - if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { - return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }) -} - -const OLD_ENV = process.env -const OLD_FETCH = globalThis.fetch - -describe('Azure DevOps client', () => { - beforeEach(() => { - process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' } - gitExecFileAsyncMock.mockReset() - _resetAzureDevOpsRepoRefCache() - _resetAzureDevOpsApiVersionCache() - __resetRepoDefaultBranchCacheForTests() - }) - - afterEach(() => { - process.env = OLD_ENV - globalThis.fetch = OLD_FETCH - _resetAzureDevOpsRepoRefCache() - }) - - it('authenticates against an on-prem Server that requires -preview api-versions (STA-3494)', async () => { - process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'https://ado.example.com:8443/tfs/MyCollection' - const versions: (string | null)[] = [] - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { - const url = new URL(String(input)) - expect(url.pathname).toBe('/tfs/MyCollection/_apis/connectionData') - versions.push(url.searchParams.get('api-version')) - if (!url.searchParams.get('api-version')?.endsWith('-preview')) { - return new Response( - JSON.stringify({ - message: 'The requested version "7.1" of the resource is under preview.', - typeKey: 'VssInvalidPreviewVersionException' - }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ) - } - return Response.json({ authenticatedUser: { providerDisplayName: 'Server User' } }) - }) as never - - await expect(getAzureDevOpsAuthStatus()).resolves.toEqual({ - configured: true, - authenticated: true, - account: 'Server User', - baseUrl: 'https://ado.example.com:8443/tfs/MyCollection', - tokenConfigured: true - }) - expect(versions).toEqual(['7.1', '7.1-preview']) - }) - - it('resolves a PR for a branch through repository, PR, and status REST calls', async () => { - gitExecFileAsyncMock.mockResolvedValue({ - stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' - }) - const requests: { pathname: string; search: string; authorization: string | null }[] = [] - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { - const url = new URL(String(input)) - requests.push({ - pathname: url.pathname, - search: url.search, - authorization: null - }) - const response = (body: unknown): Response => - new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - - if (url.pathname === '/acme/Project/_apis/git/repositories/repo') { - return response({ - id: 'repo-guid', - webUrl: 'https://dev.azure.com/acme/Project/_git/repo' - }) - } - if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') { - expect(url.searchParams.get('searchCriteria.sourceRefName')).toBe( - 'refs/heads/feature/azure' - ) - expect(url.searchParams.get('searchCriteria.status')).toBe('all') - return response({ - value: [ - { - pullRequestId: 17, - title: 'Old Azure PR', - status: 'completed', - creationDate: '2026-05-10T00:00:00Z', - lastMergeSourceCommit: { commitId: 'oldsha' } - }, - { - pullRequestId: 18, - title: 'Azure branch', - status: 'active', - creationDate: '2026-05-11T00:00:00Z', - mergeStatus: 'succeeded', - lastMergeSourceCommit: { commitId: 'abc123' } - } - ] - }) - } - if ( - url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/18/statuses' - ) { - return response({ value: [{ state: 'succeeded' }] }) - } - return new Response(JSON.stringify({ message: 'not found' }), { status: 404 }) - }) as never - - await expect( - getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure') - ).resolves.toEqual({ - number: 18, - title: 'Azure branch', - state: 'open', - url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/18', - status: 'success', - checksSummary: { - state: 'success', - total: 1, - passed: 1, - failed: 0, - cancelled: 0, - pending: 0, - neutral: 0 - }, - updatedAt: '2026-05-11T00:00:00Z', - mergeable: 'MERGEABLE', - headSha: 'abc123' - }) - expect(requests.map((request) => request.pathname)).toEqual([ - '/acme/Project/_apis/git/repositories/repo', - '/acme/Project/_apis/git/repositories/repo-guid/pullRequests', - '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/18/statuses' - ]) - }) - - it('getAzureDevOpsPullRequestForBranchOrThrow surfaces a list failure instead of null (finding 4)', async () => { - gitExecFileAsyncMock.mockResolvedValue({ - stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' - }) - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { - const url = new URL(String(input)) - if (url.pathname === '/acme/Project/_apis/git/repositories/repo') { - return new Response( - JSON.stringify({ - id: 'repo-guid', - webUrl: 'https://dev.azure.com/acme/Project/_git/repo' - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - } - return new Response(JSON.stringify({ message: 'forbidden' }), { status: 403 }) - }) as never - - await expect( - getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure') - ).resolves.toBeNull() - _resetAzureDevOpsRepoRefCache() - await expect( - getAzureDevOpsPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/azure') - ).rejects.toThrow(/Azure DevOps request failed/) - }) - - it.each<{ - name: string - prBody: unknown - prContinuation?: boolean - commitFails?: boolean - commitContinuation?: boolean - commitRequests?: number - complete: boolean - }>([ - { name: 'failed PR source', prBody: null, complete: false }, - { name: 'malformed PR source', prBody: {}, complete: false }, - { - name: 'continuing empty PR source', - prBody: { value: [] }, - prContinuation: true, - complete: false - }, - { name: 'complete empty PR source', prBody: { value: [] }, complete: true }, - { name: 'failed commit fallback', prBody: { value: [] }, commitFails: true, complete: false }, - { - name: 'continuing commit fallback', - prBody: { value: [] }, - commitContinuation: true, - complete: false - }, - { - name: 'continuing nonempty PR source', - prBody: { value: [{ state: 'canceled' }] }, - prContinuation: true, - commitRequests: 0, - complete: false - } - ])( - 'preserves cancellation authority with $name', - async ({ - prBody, - prContinuation, - commitFails, - commitContinuation, - commitRequests, - complete - }) => { - primeGitExecWithDefaultBranch() - const requests: string[] = [] - const response = (body: unknown, continuation = false): Response => - body === null - ? new Response('Unavailable', { status: 500 }) - : Response.json(body, { - headers: continuation ? { 'x-ms-continuationtoken': 'next' } : {} - }) - globalThis.fetch = vi.fn(async (input) => { - const path = new URL(String(input)).pathname - requests.push(path) - if (path.endsWith('/repositories/repo')) { - return Response.json({ id: 'repo-guid' }) - } - if (path.endsWith('/pullRequests')) { - return Response.json({ - value: [ - { - pullRequestId: 18, - title: 'Azure branch', - status: 'active', - lastMergeSourceCommit: { commitId: 'head' } - } - ] - }) - } - if (path.endsWith('/pullRequests/18/statuses')) { - return response(prBody, prContinuation) - } - if (path.endsWith('/commits/head/statuses')) { - return response( - commitFails ? null : { value: [{ state: 'canceled' }] }, - commitContinuation - ) - } - throw new Error(`Unexpected request: ${path}`) - }) - - const pr = await getAzureDevOpsPullRequestForBranch('/repo', 'feature/azure') - expect(pr?.status).toBe(commitFails ? 'neutral' : 'failure') - expect(pr?.checksSummary).toEqual( - complete - ? { - state: 'failure', - total: 1, - passed: 0, - failed: 1, - cancelled: 1, - pending: 0, - neutral: 0 - } - : undefined - ) - expect(requests.filter((path) => path.includes('/commits/'))).toHaveLength( - commitRequests ?? 1 - ) - } - ) - - it('keeps embedded statuses without a source SHA conservative', async () => { - primeGitExecWithDefaultBranch() - globalThis.fetch = vi.fn(async (input) => { - const path = new URL(String(input)).pathname - if (path.endsWith('/repositories/repo')) { - return Response.json({ id: 'repo-guid' }) - } - if (path.endsWith('/pullRequests')) { - return Response.json({ - value: [ - { - pullRequestId: 18, - title: 'Azure branch', - status: 'active', - statuses: [{ state: 'canceled' }] - } - ] - }) - } - if (path.endsWith('/pullRequests/18/statuses')) { - return Response.json({ value: [] }) - } - throw new Error(`Unexpected request: ${path}`) - }) - const pr = await getAzureDevOpsPullRequestForBranch('/repo', 'feature/azure') - expect(pr?.status).toBe('failure') - expect(pr?.checksSummary).toBeUndefined() - expect(globalThis.fetch).toHaveBeenCalledTimes(3) - }) - - it('uses the most recent branch PR instead of preferring stale active PRs', async () => { - gitExecFileAsyncMock.mockResolvedValue({ - stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' - }) - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { - const url = new URL(String(input)) - const response = (body: unknown): Response => - new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - - if (url.pathname === '/acme/Project/_apis/git/repositories/repo') { - return response({ id: 'repo-guid' }) - } - if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') { - return response({ - value: [ - { - pullRequestId: 20, - title: 'Stale active PR', - status: 'active', - creationDate: '2026-05-10T00:00:00Z', - mergeStatus: 'succeeded', - lastMergeSourceCommit: { commitId: 'oldsha' } - }, - { - pullRequestId: 21, - title: 'Latest completed PR', - status: 'completed', - creationDate: '2026-05-15T00:00:00Z', - closedDate: '2026-05-16T00:00:00Z', - mergeStatus: 'succeeded', - lastMergeSourceCommit: { commitId: 'newsha' } - } - ] - }) - } - if ( - url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/21/statuses' - ) { - return response({ value: [{ state: 'succeeded' }] }) - } - return new Response(JSON.stringify({ message: 'not found' }), { status: 404 }) - }) as never - - await expect( - getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure') - ).resolves.toMatchObject({ - number: 21, - title: 'Latest completed PR', - state: 'merged', - headSha: 'newsha' - }) - }) - - it('hides a stale completed/abandoned PR whose source branch is the repo default branch (#9171)', async () => { - primeGitExecWithDefaultBranch() - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { - const url = new URL(String(input)) - const response = (body: unknown): Response => - new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - - if (url.pathname === '/acme/Project/_apis/git/repositories/repo') { - return response({ id: 'repo-guid' }) - } - if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') { - return response({ - value: [ - { - pullRequestId: 30, - title: 'Accidental PR from main', - status: 'abandoned', - creationDate: '2026-05-10T00:00:00Z', - closedDate: '2026-05-11T00:00:00Z', - lastMergeSourceCommit: { commitId: 'stale-main-oid' } - } - ] - }) - } - return new Response(JSON.stringify({ message: 'not found' }), { status: 404 }) - }) as never - - await expect(getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/main')).resolves.toBeNull() - }) - - it('discards a completed default-branch shadow and refetches the linked PR (#9171)', async () => { - primeGitExecWithDefaultBranch() - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { - const url = new URL(String(input)) - const response = (body: unknown): Response => - new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - - if (url.pathname === '/acme/Project/_apis/git/repositories/repo') { - return response({ id: 'repo-guid' }) - } - if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') { - return response({ - value: [ - { - pullRequestId: 32, - title: 'Completed PR from main', - status: 'completed', - creationDate: '2026-05-10T00:00:00Z', - closedDate: '2026-05-12T00:00:00Z', - lastMergeSourceCommit: { commitId: 'shadow-oid' } - } - ] - }) - } - if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/40') { - return response({ - pullRequestId: 40, - title: 'Linked PR', - status: 'active', - creationDate: '2026-05-13T00:00:00Z', - mergeStatus: 'succeeded', - lastMergeSourceCommit: { commitId: 'linked-oid' } - }) - } - if ( - url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/40/statuses' - ) { - return response({ value: [{ state: 'succeeded' }] }) - } - return new Response(JSON.stringify({ message: 'not found' }), { status: 404 }) - }) as never - - await expect( - getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/main', 40) - ).resolves.toMatchObject({ number: 40, state: 'open' }) - }) - - it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { - gitExecFileAsyncMock.mockResolvedValue({ - stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' - }) - let cancelledBodies = 0 - const fetchMock = vi.fn(async () => - cancelTrackingResponse(502, () => { - cancelledBodies += 1 - }) - ) - globalThis.fetch = fetchMock as never - - await getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure') - - expect(fetchMock).toHaveBeenCalled() - expect(cancelledBodies).toBe(fetchMock.mock.calls.length) - }) -}) diff --git a/src/main/bitbucket/client.test.ts b/src/main/bitbucket/client.test.ts deleted file mode 100644 index cf442307312..00000000000 --- a/src/main/bitbucket/client.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' - -const { gitExecFileAsyncMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn() -})) - -vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal>()), - gitExecFileAsync: gitExecFileAsyncMock -})) - -import { - getBitbucketAuthStatus, - getBitbucketPullRequestForBranch, - getBitbucketPullRequestForBranchOrThrow -} from './client' -import { _resetBitbucketRepoRefCache } from './repository-ref' -import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' - -function primeGitExecWithDefaultBranch(defaultRef = 'refs/remotes/origin/main'): void { - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'git@bitbucket.org:team/repo.git\n', stderr: '' } - } - if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { - return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }) -} - -const OLD_ENV = process.env - -function bitbucketPr(id = 7) { - return { - id, - title: 'Add Bitbucket', - state: 'OPEN', - updated_on: '2026-05-10T00:00:00.000Z', - links: { html: { href: `https://bitbucket.org/team/repo/pull-requests/${id}` } }, - source: { - branch: { name: 'feature/bitbucket' }, - commit: { hash: 'abc123' }, - repository: { full_name: 'team/repo' } - }, - destination: { - branch: { name: 'main' }, - repository: { full_name: 'team/repo' } - } - } -} - -describe('Bitbucket client', () => { - beforeEach(() => { - process.env = { ...OLD_ENV } - process.env.ORCA_BITBUCKET_API_BASE_URL = 'https://api.test.local/2.0' - process.env.ORCA_BITBUCKET_EMAIL = 'user@example.com' - process.env.ORCA_BITBUCKET_API_TOKEN = 'token' - delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockResolvedValue({ - stdout: 'git@bitbucket.org:team/repo.git\n', - stderr: '' - }) - _resetBitbucketRepoRefCache() - __resetRepoDefaultBranchCacheForTests() - vi.unstubAllGlobals() - }) - - it('hides a stale DECLINED PR whose source branch is the repo default branch (#9171)', async () => { - primeGitExecWithDefaultBranch() - const fetchMock = vi.fn(async (url: string) => { - if (url.includes('/statuses/build')) { - return Response.json({ values: [] }) - } - return Response.json({ - values: [{ ...bitbucketPr(7), state: 'DECLINED', source: { branch: { name: 'main' } } }] - }) - }) - vi.stubGlobal('fetch', fetchMock) - - await expect(getBitbucketPullRequestForBranch('/repo', 'refs/heads/main')).resolves.toBeNull() - }) - - it('hides a MERGED PR matched only by feature-branch name so a new PR can be created', async () => { - const fetchMock = vi.fn(async (url: string) => { - if (url.includes('/statuses/build')) { - return Response.json({ values: [] }) - } - return Response.json({ - values: [ - { ...bitbucketPr(7), state: 'MERGED', source: { branch: { name: 'feature/login' } } } - ] - }) - }) - vi.stubGlobal('fetch', fetchMock) - - await expect( - getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login') - ).resolves.toBeNull() - }) - - it('keeps a DECLINED PR on a feature branch visible', async () => { - const fetchMock = vi.fn(async (url: string) => { - if (url.includes('/statuses/build')) { - return Response.json({ values: [] }) - } - return Response.json({ - values: [ - { ...bitbucketPr(9), state: 'DECLINED', source: { branch: { name: 'feature/login' } } } - ] - }) - }) - vi.stubGlobal('fetch', fetchMock) - - await expect( - getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login') - ).resolves.toMatchObject({ number: 9, state: 'closed' }) - }) - - it('returns null instead of querying anonymously when no credential resolves', async () => { - delete process.env.ORCA_BITBUCKET_EMAIL - delete process.env.ORCA_BITBUCKET_API_TOKEN - const fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) - - await expect( - getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login') - ).resolves.toBeNull() - expect(fetchMock).not.toHaveBeenCalled() - }) - - it('fetches an explicitly linked DECLINED PR before the branch index', async () => { - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith('/pullrequests/7')) { - return Response.json({ ...bitbucketPr(7), state: 'DECLINED' }) - } - if (url.includes('/statuses/build')) { - return Response.json({ values: [] }) - } - return new Response('branch index unavailable', { status: 503 }) - }) - vi.stubGlobal('fetch', fetchMock) - - await expect( - getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 7) - ).resolves.toMatchObject({ number: 7, state: 'closed' }) - expect(fetchMock.mock.calls.map(([url]) => url)).not.toContainEqual( - expect.stringContaining('/pullrequests?') - ) - }) - - it('falls back to the branch index when a linked PR number is stale', async () => { - const branchPR = bitbucketPr(7) - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith('/pullrequests/99')) { - return new Response('not found', { status: 404 }) - } - if (url.includes('/statuses/build')) { - return Response.json({ values: [] }) - } - return Response.json({ - values: [{ ...branchPR, source: { ...branchPR.source, branch: { name: 'feature/login' } } }] - }) - }) - vi.stubGlobal('fetch', fetchMock) - - await expect( - getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 99) - ).resolves.toMatchObject({ number: 7, state: 'open' }) - expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ - expect.stringContaining('/pullrequests/99'), - expect.stringContaining('/pullrequests?'), - expect.stringContaining('/statuses/build') - ]) - }) - - it('does not report an unreachable host as an auth failure (STA-3944)', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - throw new Error('network down') - }) - ) - await expect(getBitbucketAuthStatus()).resolves.toMatchObject({ - configured: true, - authenticated: true - }) - - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response(null, { status: 401 })) - ) - await expect(getBitbucketAuthStatus()).resolves.toMatchObject({ - configured: true, - authenticated: false - }) - }) - - it('fetches a branch pull request and commit build status', async () => { - const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { - if (url.includes('/statuses/build')) { - return Response.json({ values: [{ state: 'SUCCESSFUL' }] }) - } - return Response.json({ values: [bitbucketPr()] }) - }) - vi.stubGlobal('fetch', fetchMock) - - await expect( - getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/bitbucket') - ).resolves.toEqual({ - number: 7, - title: 'Add Bitbucket', - state: 'open', - url: 'https://bitbucket.org/team/repo/pull-requests/7', - status: 'success', - checksSummary: { - state: 'success', - total: 1, - passed: 1, - failed: 0, - cancelled: 0, - pending: 0, - neutral: 0 - }, - updatedAt: '2026-05-10T00:00:00.000Z', - mergeable: 'UNKNOWN', - headSha: 'abc123' - }) - - const firstCall = fetchMock.mock.calls[0] - const listUrl = String(firstCall?.[0]) - const listInit = firstCall?.[1] - if (!listInit) { - throw new Error('expected request init') - } - const parsed = new URL(listUrl) - expect(parsed.pathname).toBe('/2.0/repositories/team/repo/pullrequests') - expect(parsed.searchParams.get('q')).toBe( - 'source.branch.name = "feature/bitbucket" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")' - ) - expect(parsed.searchParams.getAll('state')).toEqual([ - 'OPEN', - 'MERGED', - 'DECLINED', - 'SUPERSEDED' - ]) - expect((listInit.headers as Record).Authorization).toBe( - `Basic ${Buffer.from('user@example.com:token').toString('base64')}` - ) - }) - - it('getBitbucketPullRequestForBranchOrThrow surfaces a failure instead of null (finding 4)', async () => { - const fetchMock = vi.fn(async () => Response.json({ error: 'forbidden' }, { status: 403 })) - vi.stubGlobal('fetch', fetchMock) - - await expect(getBitbucketPullRequestForBranch('/repo', 'feature/bitbucket')).resolves.toBeNull() - await expect( - getBitbucketPullRequestForBranchOrThrow('/repo', 'feature/bitbucket') - ).rejects.toThrow(/Bitbucket request failed/) - }) - - it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { - let cancelledBodies = 0 - const fetchMock = vi.fn(async () => - cancelTrackingResponse(502, () => { - cancelledBodies += 1 - }) - ) - vi.stubGlobal('fetch', fetchMock) - - await getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/bitbucket') - - expect(fetchMock).toHaveBeenCalled() - expect(cancelledBodies).toBe(fetchMock.mock.calls.length) - }) -}) diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts index 2990603f7e5..01a0b743d16 100644 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -6,10 +6,7 @@ import { getDefaultSettings } from '../../shared/constants' import type { GlobalSettings } from '../../shared/global-settings-types' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { CLAUDE_SIGN_IN_NOT_FINISHED_MESSAGE } from './claude-account-registration' -import { - CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE, - CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE -} from './claude-account-selection' +import { CLAUDE_ACCOUNT_FOLDER_IN_USE_MESSAGE } from './claude-account-selection' import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' import { describeClaudeProfile } from './claude-profile-paths' import type { ClaudeAccountSelectionTarget } from './runtime-selection' @@ -105,59 +102,6 @@ function handOverClaudeSignInLink(windowsPath: string, link: string): void { } describe('ClaudeAccountService', () => { - it("labels each row with its folder's login and asks a folder without one to sign in", () => { - const f = fixture([ - account('a'), - account('b'), - account('old-wsl', { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/old-wsl/auth' - }), - account('new-wsl', { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/new-wsl/home' - }) - ]) - f.signIn('a', 'now-a@example.test') - const byId = new Map(f.service.listAccounts().accounts.map((row) => [row.id, row])) - expect(byId.get('a')).toMatchObject({ email: 'now-a@example.test' }) - expect(byId.get('a')?.needsSignIn).toBeUndefined() - expect(byId.get('b')).toMatchObject({ email: 'b@example.test', needsSignIn: true }) - expect(byId.get('old-wsl')).toMatchObject({ needsSignIn: true }) - expect(byId.get('new-wsl')?.needsSignIn).toBeUndefined() - }) - - it('asks no sign-in of an account System default runs, and lets it be selected', async () => { - const f = fixture() - f.covered.add('b') - const row = f.service.listAccounts().accounts.find((candidate) => candidate.id === 'b') - expect(row?.needsSignIn).toBeUndefined() - expect(row?.runsOnSystemDefault).toBe(true) - await expect(f.service.selectAccount('b')).resolves.toMatchObject({ activeAccountId: 'b' }) - }) - - it("reports System default's login from the user's own folder", () => { - const f = fixture() - mkdirSync(join(f.root, 'personal')) - writeFileSync( - join(f.root, 'personal', '.claude.json'), - JSON.stringify({ - oauthAccount: { emailAddress: 'me@example.test', organizationUuid: 'me-org' } - }) - ) - expect(f.service.listAccounts()).toMatchObject({ - systemDefaultEmail: 'me@example.test', - systemDefaultOrganizationUuid: 'me-org', - systemDefaultHome: join(f.root, 'personal') - }) - expect(f.service.listAccounts()).not.toHaveProperty('userClaudeConfigDir') - expect(f.service.listAccounts().systemDefaultMayBeCopied).toBeUndefined() - mkdirSync(join(f.root, 'claude-runtime-auth')) - expect(f.service.listAccounts().systemDefaultMayBeCopied).toBe(true) - }) - it('saves an account only once its folder holds a login', async () => { const f = fixture() const begun = await f.service.beginSignIn({ runtime: 'host' }) @@ -177,17 +121,6 @@ describe('ClaudeAccountService', () => { }) }) - it('refuses a second account for the same login and deletes its folder', async () => { - const f = fixture() - const begun = await f.service.beginSignIn({ runtime: 'host' }) - f.signIn(begun.accountId, 'B@example.test') - await expect(f.service.finishSignIn(begun)).rejects.toThrow('already added') - expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { - runtime: 'host' - }) - expect(f.settings().claudeManagedAccounts).toHaveLength(2) - }) - it.each([ { email: 'new@example.test', organizationUuid: 'new-org', duplicate: false }, { email: 'imported@example.test', organizationUuid: 'saved-org', duplicate: true }, @@ -240,17 +173,6 @@ describe('ClaudeAccountService', () => { } ) - it('relabels a saved account signed in again under another login', async () => { - const f = fixture() - const begun = await f.service.beginSignIn({ accountId: 'b' }) - f.signIn('b', 'other@example.test') - await f.service.finishSignIn(begun) - expect(f.settings().claudeManagedAccounts.find((entry) => entry.id === 'b')).toMatchObject({ - email: 'other@example.test', - createdAt: 1 - }) - }) - it.each([ { savedEmail: 'old@example.test', currentEmail: 'current@example.test' }, { savedEmail: 'same@example.test', currentEmail: 'same@example.test' } @@ -281,40 +203,6 @@ describe('ClaudeAccountService', () => { } ) - it('signs in with a hidden login straight into the new account folder', async () => { - const f = fixture() - f.runLogin.mockImplementationOnce(async (_args, config) => { - f.signIn(newId(f), 'new@example.test') - expect(config).toEqual({ windowsPath: f.home(newId(f)), linuxPath: null, wslDistro: null }) - return '' - }) - await f.service.addAccount({ runtime: 'host' }) - expect(f.runLogin).toHaveBeenCalledWith( - ['auth', 'login', '--claudeai'], - expect.anything(), - expect.any(Number), - expect.objectContaining({ keepStdinOpen: true }) - ) - expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ - id: newId(f), - email: 'new@example.test', - managedAuthPath: f.home(newId(f)) - }) - expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() - }) - - it('lets Claude open the browser itself for a plain sign-in', async () => { - const f = fixture() - f.runLogin.mockImplementationOnce(async (_args, config, _timeoutMs, options) => { - expect(options?.browser).toBeUndefined() - expect(existsSync(join(config.windowsPath, '.orca-sign-in-browser'))).toBe(false) - f.signIn(newId(f), 'new@example.test') - return '' - }) - await f.service.addAccount({ runtime: 'host' }) - expect(f.runLogin).toHaveBeenCalledTimes(1) - }) - it.skipIf(process.platform === 'win32')( 'hands the link Claude gives BROWSER to Settings when asked to copy it', async () => { @@ -336,18 +224,6 @@ describe('ClaudeAccountService', () => { } ) - it('fails a copy-link sign-in it cannot catch, rather than opening a browser', async () => { - const f = fixture() - f.runtimeAuth.prepareAccountFolder.mockImplementationOnce(async (id: string) => ({ - configDir: join(f.root, id, 'missing'), - readPath: join(f.root, id, 'missing') - })) - const adding = f.service.addAccount({ runtime: 'host' }, true) - await expect(f.service.waitForSignInLink()).resolves.toBeNull() - await expect(adding).rejects.toThrow('Claude sign-in failed. Please try again.') - expect(f.runLogin).not.toHaveBeenCalled() - }) - it.skipIf(process.platform === 'win32')( 'ends a copy-link sign-in when Claude hands BROWSER something other than a sign-in link', async () => { @@ -367,61 +243,6 @@ describe('ClaudeAccountService', () => { } ) - it('reports no link when a copy-link sign-in ends before Claude hands one over', async () => { - const f = fixture() - f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in was cancelled.')) - const adding = f.service.reauthenticateAccount('b', true) - await expect(f.service.waitForSignInLink()).resolves.toBeNull() - await expect(adding).rejects.toThrow('cancelled') - }) - - it('opens the link of a WSL sign-in through the desktop opener', async () => { - const LINK = - 'https://claude.com/cai/oauth/authorize?redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback' - const f = fixture() - const opened: string[] = [] - f.service.setWslSignInLinkOpener((link) => opened.push(link)) - f.runtimeAuth.prepareAccountFolder.mockImplementation(async (id: string) => { - mkdirSync(f.home(id), { recursive: true }) - return { configDir: `/home/u/claude-profiles/${id}/home`, readPath: f.home(id) } - }) - f.runLogin.mockImplementationOnce(async (_args, config, _timeoutMs, options) => { - expect(options?.browser).toBe( - `/home/u/claude-profiles/${newId(f)}/home/.orca-sign-in-browser` - ) - handOverClaudeSignInLink(config.windowsPath, LINK) - await vi.waitFor(() => expect(opened).toEqual([LINK])) - f.signIn(newId(f), 'wsl@example.test') - return '' - }) - await f.service.addAccount({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(opened).toEqual([LINK]) - }) - - it('runs a WSL login against the guest folder', async () => { - const f = fixture() - f.runtimeAuth.prepareAccountFolder.mockImplementation(async (id: string) => { - mkdirSync(f.home(id), { recursive: true }) - return { configDir: `/home/u/claude-profiles/${id}/home`, readPath: f.home(id) } - }) - f.runLogin.mockImplementationOnce(async (_args, config) => { - f.signIn(newId(f), 'wsl@example.test') - expect(config).toEqual({ - windowsPath: f.home(newId(f)), - linuxPath: `/home/u/claude-profiles/${newId(f)}/home`, - wslDistro: 'Ubuntu' - }) - return '' - }) - await f.service.addAccount({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(f.settings().claudeManagedAccounts.at(-1)).toMatchObject({ - email: 'wsl@example.test', - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: `/home/u/claude-profiles/${newId(f)}/home` - }) - }) - it('leaves no row or folder when the hidden login is cancelled', async () => { const f = fixture() f.runLogin.mockImplementationOnce( @@ -442,30 +263,6 @@ describe('ClaudeAccountService', () => { expect(existsSync(join(f.root, newId(f)))).toBe(false) }) - it('leaves no row or folder when the hidden login times out or finds no login', async () => { - const f = fixture() - f.runLogin.mockRejectedValueOnce(new Error('Claude sign-in took too long to finish.')) - await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow('took too long') - await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'could not resolve the account email' - ) - expect(f.settings().claudeManagedAccounts).toHaveLength(2) - expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledTimes(2) - }) - - it('refuses a hidden login to an already added account and deletes its folder', async () => { - const f = fixture() - f.runLogin.mockImplementationOnce(async () => { - f.signIn(newId(f), 'B@example.test') - return '' - }) - await expect(f.service.addAccount({ runtime: 'host' })).rejects.toThrow( - 'This Claude account is already added.' - ) - expect(f.settings().claudeManagedAccounts).toHaveLength(2) - expect(existsSync(join(f.root, newId(f)))).toBe(false) - }) - it('signs a saved account in again inside its own folder and keeps it on failure', async () => { const f = fixture() f.runLogin.mockImplementationOnce(async (_args, config) => { @@ -485,28 +282,6 @@ describe('ClaudeAccountService', () => { expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) }) - it('clears the selection, republishes, then deletes the folder on remove', async () => { - const f = fixture() - f.signIn('a', 'a@example.test') - await f.service.removeAccount('a') - expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['b']) - expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() - expect(f.runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) - expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith('a', { runtime: 'host' }) - }) - - it("deletes a cancelled sign-in's folder but never a saved account's", async () => { - const f = fixture() - const begun = await f.service.beginSignIn({ runtime: 'host' }) - await f.service.cancelSignIn({ accountId: begun.accountId, runtime: 'host' }) - expect(f.runtimeAuth.removeAccountFolder).toHaveBeenCalledWith(begun.accountId, { - runtime: 'host' - }) - f.runtimeAuth.removeAccountFolder.mockClear() - await f.service.cancelSignIn({ accountId: 'a', runtime: 'host' }) - expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() - }) - it('keeps the row when its folder cannot be deleted, so the user can retry', async () => { const f = fixture() f.runtimeAuth.removeAccountFolder.mockRejectedValueOnce( @@ -516,60 +291,4 @@ describe('ClaudeAccountService', () => { expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBeNull() }) - - it('puts the previous account back when publishing a new selection fails', async () => { - const f = fixture() - f.signIn('b', 'b@example.test') - f.runtimeAuth.syncForCurrentSelection.mockRejectedValueOnce(new Error('publish failed')) - await expect(f.service.selectAccount('b')).rejects.toThrow('publish failed') - expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') - expect(f.runtimeAuth.publishAll).toHaveBeenCalled() - }) - - it('refuses to select an account whose folder holds no login', async () => { - const f = fixture() - await expect(f.service.selectAccount('b')).rejects.toThrow(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) - expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') - f.signIn('b', 'b@example.test') - await f.service.selectAccount('b') - expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('b') - }) - - it('refuses to select a WSL account for the host', async () => { - const f = fixture([ - account('a'), - account('w', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) - ]) - await expect(f.service.selectAccountForTarget('w', { runtime: 'host' })).rejects.toThrow( - 'That Claude account belongs to a different runtime.' - ) - }) - - it('selects and clears a WSL account across casing while retaining its recorded distro', async () => { - const f = fixture([ - account('a'), - account('w', { - managedAuthRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/w/home' - }) - ]) - const selected = await f.service.selectAccountForTarget('w', { - runtime: 'wsl', - wslDistro: 'ubuntu' - }) - expect(selected.activeAccountIdsByRuntime).toEqual({ host: 'a', wsl: { Ubuntu: 'w' } }) - expect(f.runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ - runtime: 'wsl', - wslDistro: 'Ubuntu' - }) - await expect( - f.service.selectAccountForTarget('w', { runtime: 'wsl', wslDistro: 'Debian' }) - ).rejects.toThrow('different runtime') - const cleared = await f.service.selectAccountForTarget(null, { - runtime: 'wsl', - wslDistro: 'ubuntu' - }) - expect(cleared.activeAccountIdsByRuntime).toEqual({ host: 'a', wsl: { Ubuntu: null } }) - }) }) diff --git a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts deleted file mode 100644 index cd0a6508fb1..00000000000 --- a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - existsSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - statSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' -import { expect, it } from 'vitest' -import { stripComments } from '../../shared/source-scan/source-tree-scan' - -const src = join(__dirname, '../..') - -const credentialRoots: readonly (string | { root: string; only: RegExp })[] = [ - 'main/claude-accounts', - // Other providers may refresh their own credentials. - { root: 'main/rate-limits', only: /[\\/]claude[^\\/]*\.ts$/ }, - 'main/claude', - 'main/native-chat', - 'main/ipc/pty', - 'main/daemon', - 'main/providers', - 'main/macos-keychain', - 'cli', - 'relay', - 'shared' -] - -// New persistence primitives require explicit review, even in an allowed module. -const fsAllowances: Record = { - 'claude-accounts/claude-profile-history.ts': [ - 'mkdirSync', - 'renameSync', - 'rmdirSync', - 'symlinkSync', - 'unlinkSync' - ], - // Stores Claude config folder paths only. - 'claude-accounts/claude-observed-config-dirs.ts': ['writeFileAtomically'], - // Invalidates a validated owned completion marker before rebuilding a missing home. - 'claude-accounts/claude-profile-paths.ts': ['mkdirSync', 'unlinkSync', 'writeFileAtomically'], - 'claude-accounts/claude-profile-router.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], - 'claude-accounts/claude-account-folder.ts': ['rmSync', 'rmdirSync', 'unlinkSync'], - // This write targets process stdin, not a file. - 'claude-accounts/claude-command-process.ts': ['write'], - 'claude-accounts/claude-profile-prompt-history.ts': [ - 'appendFileSync', - 'linkSync', - 'openSync', - 'renameSync', - 'rmSync', - 'symlinkSync', - 'unlinkSync', - 'writeFileSync', - 'writeFileAtomically' - ], - 'claude-accounts/claude-profile-provisioning.ts': [ - 'publishFileWithoutOverwrite', - 'rmSync', - 'writeFileSync' - ], - // Writes the account's copy of the user's settings and a digest-only sync record. - 'claude-accounts/claude-profile-settings.ts': ['writeFileAtomically'], - 'claude-accounts/claude-profile-sharing.ts': [ - 'mkdirSync', - 'rmdirSync', - 'symlinkSync', - 'unlinkSync', - 'writeFileAtomically' - ], - // The browser helper passes a sign-in link, not credentials. - 'claude-accounts/claude-sign-in-browser.ts': ['rm', 'writeFile'], - // This write targets stdout, not a file. - 'claude-accounts/claude-profile-wsl-entry.ts': ['write'] -} -const mutation = - /^(?:write(?:File|Json|Sync|v|.*Credentials|.*Keychain)?$|write(?:File|Json)|appendFile|copyFile|cp(?:Sync)?$|rename(?:Sync)?$|unlink(?:Sync)?$|rm(?:dir)?(?:Sync)?$|truncate(?:Sync)?$|createWriteStream|symlink(?:Sync)?$|link(?:Sync)?$|chmod(?:Sync)?$|mkdir(?:Sync)?$|mkdtemp(?:Sync)?$|open(?:Sync)?$|delete.*Keychain|refreshClaudeOauth)/ - -function persistencePrimitives(text: string): string[] { - const found = new Set() - // Import-side names survive aliases, including destructuring and wrapped helper calls. - for (const match of text.matchAll(/\b([a-zA-Z_$][\w$]*)\s*(?=\(|as\s|[:,}])/g)) { - if (mutation.test(match[1])) { - found.add(match[1]) - } - } - return [...found] -} - -const WRITE_CALL = - /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream)\s*\(/g -// Only this runner may forward arbitrary security argv. -const SECURITY_RUNNER = 'main/macos-keychain/generic-password.ts' -// Only account removal may delete Keychain items. -const KEYCHAIN_DELETE_OWNER = 'main/claude-accounts/claude-account-folder.ts' - -/** Follow aliases of credential file paths. */ -function credentialFileNames(text: string): Set { - const declarations: { name: string; init: string }[] = [] - const lines = text.split('\n') - lines.forEach((line, index) => { - const declared = /^(\s*)(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) - if (!declared) { - return - } - const indent = declared[1].length - let end = index + 1 - while (end < lines.length && /^\s*$|^\s+/.test(lines[end])) { - const lead = /^\s*/.exec(lines[end])?.[0].length ?? 0 - if (lines[end].trim() && lead <= indent && !/^\s*[)\]}]/.test(lines[end])) { - break - } - end += 1 - } - declarations.push({ name: declared[2], init: lines.slice(index, end).join('\n') }) - }) - const names = new Set() - for (let changed = true; changed;) { - changed = false - for (const { name, init } of declarations) { - const tainted = - init.includes('.credentials.json') || - [...names].some((other) => new RegExp(`\\b${other}\\b`).test(init.replace(name, ''))) - if (tainted && !names.has(name)) { - names.add(name) - changed = true - } - } - } - return names -} - -function credentialMutations(text: string, file = ''): string[] { - const found: string[] = [] - // Computed security verbs could hide writes; require literal verbs outside the runner. - if ( - (file !== SECURITY_RUNNER && - /\b(?:exec(?:File)?(?:Sync|Async)?|spawn(?:Sync)?|runProcess|spawnProcess)\s*\(\s*['"`]security['"`]\s*,\s*(?!\[\s*['"])/.test( - text - )) || - /\bprogram\s*:\s*['"`]security['"`]\s*,\s*args\s*:\s*(?!\[\s*['"])/.test(text) || - /(? new RegExp(`\\b${name}\\b`).test(args))) { - found.push('credentials file write through a name') - break - } - } - if ( - /add-generic-password/.test(text) || - (file !== KEYCHAIN_DELETE_OWNER && /delete-generic-password/.test(text)) - ) { - found.push('keychain write') - } - if (/\b(?:write|delete|store|save)\w*Keychain\w*\b/.test(text)) { - found.push('keychain helper') - } - if (/oauth\/token|grant_type|\brefresh_token\b/.test(text)) { - found.push('token refresh') - } - // Cover generated shell writes as well as TypeScript calls. - for (const statement of text.split(/;|\n\s*\n/)) { - if ( - statement.includes('.credentials.json') && - /\b(?:write\w*|append\w*|copy\w*|cp(?:Sync)?|rename\w*|link\w*|symlink\w*|createWriteStream|mv|tee|install)\b|\bopen(?:Sync)?\s*\([\s\S]{0,200}?,\s*['"`](?:w|a|r\+|wx|ax)|>\s*["'$]/.test( - statement - ) - ) { - found.push('credentials file write') - } - } - return found -} - -function files(dir: string): string[] { - if (!existsSync(dir)) { - return [] - } - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const file = join(dir, entry.name) - return entry.isDirectory() - ? entry.name === 'node_modules' || entry.name === '__fixtures__' - ? [] - : files(file) - : /\.(?:ts|tsx|mts|cts|js|mjs|cjs|sh)$/.test(entry.name) && - !/\.test\.|\.spec\.|test-harness|harness\.ts|test-fixtures|test-utils|(?:^|-)test-constants\.ts$/.test( - entry.name - ) - ? [file] - : [] - }) -} - -it('excludes test constants without hiding credential mutations in neighboring production files', () => { - const dir = mkdtempSync(join(tmpdir(), 'orca-credential-scan-')) - const productionFiles = [ - 'credential-refresh.ts', - 'pty-ipc-test-constants-reader.ts', - 'contest-constants.ts' - ] - try { - for (const name of ['pty-ipc-test-constants.ts', ...productionFiles]) { - writeFileSync(join(dir, name), "fetch('/oauth/token', { body: 'refresh_token' })") - } - const scanned = files(dir) - expect(scanned.map((file) => relative(dir, file)).sort()).toEqual(productionFiles.sort()) - for (const file of scanned) { - expect(credentialMutations(readFileSync(file, 'utf8'))).toEqual(['token refresh']) - } - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -it('keeps every Claude credential writer removed, in every form and on every launch path', () => { - const violations = credentialRoots - .flatMap((entry) => { - const { root, only } = typeof entry === 'string' ? { root: entry, only: null } : entry - const dir = join(src, root) - const found = statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? files(dir) : [] - return only ? found.filter((file) => only.test(file)) : found - }) - .flatMap((file) => - credentialMutations( - stripComments(readFileSync(file, 'utf8')), - relative(src, file).replaceAll('\\', '/') - ).map((kind) => `${relative(src, file)}: ${kind}`) - ) - expect(violations).toEqual([]) -}) - -it('confines filesystem persistence in accounts and usage to named profile surfaces', () => { - const main = join(src, 'main') - const candidates = [ - ...files(__dirname), - ...files(join(main, 'rate-limits')).filter((file) => /\/claude[^/]*\.ts$/.test(file)) - ] - const violations = candidates.flatMap((file) => { - const name = relative(main, file).replaceAll('\\', '/') - const allowed = new Set(fsAllowances[name] ?? []) - return persistencePrimitives(stripComments(readFileSync(file, 'utf8'))) - .filter((primitive) => !allowed.has(primitive)) - .map((primitive) => `${name}: ${primitive}`) - }) - expect(violations).toEqual([]) - const cli = readFileSync(join(main, '../cli/handlers/account.ts'), 'utf8') - expect(cli).not.toMatch(/Keychain|orca-account-add-claude|addClaudeFromConfigDir/) - const auth = readFileSync(join(__dirname, 'runtime-auth-service.ts'), 'utf8') - expect(auth).not.toMatch(/extends ClaudeRuntimeAuth|snapshot|credentialsJson|readBack/i) -}) - -it.each([ - "import { writeFile as save } from 'node:fs/promises'; save(path, token)", - "import * as fs from 'node:fs'; const save = () => fs.writeFileSync(path, token)", - "import { writeFileAtomically as persist } from '../codex-accounts/fs-utils'; persist(path, token)", - 'fs.copyFile(source, destination)', - 'fs.symlink(credentialPath, destination)', - "const fd = openSync(join(dir, 'x'), 'w'); writeSync(fd, token)", - 'await fs.promises.cp(a, b)', - 'fs.cpSync(a, b, { recursive: true })' -])('mutation control: the filesystem census rejects %s', (source) => { - expect(persistencePrimitives(source).length).toBeGreaterThan(0) -}) - -it.each([ - 'security add-generic-password -s Claude -w token', - "await execFileAsync('security', ['add-generic-password', '-U', '-s', service, '-w', token])", - "execFileSync('security', ['delete-generic-password', '-s', 'Claude Code-credentials'])", - "import { writeKeychainPassword as persist } from '../macos-keychain/generic-password'; persist(service, user, token)", - "const fd = openSync(join(dir, '.credentials.json'), 'w'); writeSync(fd, token)", - "await fs.promises.cp(join(a, '.credentials.json'), join(b, '.credentials.json'))", - "copyFileSync(join(from, '.credentials.json'), join(home, '.credentials.json'))", - "const script = 'cp source .credentials.json'", - 'const script = `printf %s "$token" > "$HOME/.claude/.credentials.json"`', - "fetch('https://api.anthropic.com/v1/oauth/token')", - "body: JSON.stringify({ grant_type: 'refresh_token' })", - "execFile('security', [['add', 'generic', 'password'].join('-'), '-s', service])", - "spawn('security', verbArgs)", - 'execSecurityCommand(args)', - "const CREDENTIALS_FILE = '.credentials.json'\n\ncopyFileSync(join(from, CREDENTIALS_FILE), join(to, CREDENTIALS_FILE))", - "const name = '.credentials.json'\nfunction save(dir) {\n const target = join(dir, name)\n\n writeFileSync(target, token)\n}" -])('mutation control: the credential check rejects %s', (source) => { - expect(credentialMutations(source).length).toBeGreaterThan(0) -}) - -it.each([ - "const file = await readFile(join(home, '.credentials.json'), 'utf8')", - "readKeychainPassword('Claude Code-credentials', user)", - "execSecurityCommand(['find-generic-password', '-s', service, '-w'])", - "const file = join(home, '.credentials.json')\nconst raw = await readFile(file, 'utf8')" -])('the credential check leaves a read alone: %s', (source) => { - expect(credentialMutations(source)).toEqual([]) -}) - -it('lets only account removal delete a Keychain item, and nothing add one', () => { - const remove = "execSecurityCommand(['delete-generic-password', '-s', service])" - expect(credentialMutations(remove, KEYCHAIN_DELETE_OWNER)).toEqual([]) - expect(credentialMutations(remove, 'main/claude-accounts/service.ts')).not.toEqual([]) - expect( - credentialMutations( - "execSecurityCommand(['add-generic-password', '-s', service])", - KEYCHAIN_DELETE_OWNER - ) - ).not.toEqual([]) -}) diff --git a/src/main/claude-accounts/claude-profile-history.test.ts b/src/main/claude-accounts/claude-profile-history.test.ts index 8c5e03f8f71..25fc1db4bca 100644 --- a/src/main/claude-accounts/claude-profile-history.test.ts +++ b/src/main/claude-accounts/claude-profile-history.test.ts @@ -116,24 +116,7 @@ describe('Claude profile history sharing', () => { 'c.json' ]) }) - itLinks('keeps a session tree private across filesystems', async () => { - const f = fixture() - fs.mkdirSync(join(f.profileHome, 'plans')) - fs.writeFileSync(join(f.profileHome, 'plans/p.md'), 'plan') - const actual = vi.mocked(fs.statSync).getMockImplementation()! - vi.mocked(fs.statSync).mockImplementation((file, options) => { - const stats = actual(file, options) - return file === join(f.profileHome, 'plans') && stats - ? Object.assign(stats, { dev: -1 }) - : stats - }) - const report = await f.share() - expect(report.warnings).toContainEqual( - expect.objectContaining({ surface: 'plans', code: 'cross-filesystem' }) - ) - expect(fs.lstatSync(join(f.profileHome, 'plans')).isDirectory()).toBe(true) - expect(fs.readFileSync(join(f.profileHome, 'plans/p.md'), 'utf8')).toBe('plan') - }) + itLinks('retains prompt cursors, drains late appends and repairs a CLI replacement', async () => { const f = fixture() fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'default') @@ -160,15 +143,7 @@ describe('Claude profile history sharing', () => { fs.appendFileSync(join(f.profileHome, 'history.jsonl'), 'after-link\n') expect(f.history()).toBe('d1\np1\np2\nafter-link\n') }) - itLinks('adds only the new lines of a CLI rewrite of the shared file', async () => { - const f = fixture() - fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') - await f.share() - fs.writeFileSync(join(f.profileHome, 'rewrite'), 'd1\nd2\nnew\n') - fs.renameSync(join(f.profileHome, 'rewrite'), join(f.profileHome, 'history.jsonl')) - await f.share() - expect(f.history()).toBe('d1\nd2\nnew\n') - }) + itLinks( 'does not re-append lines after a purge drops one the shared file still has', async () => { @@ -181,42 +156,7 @@ describe('Claude profile history sharing', () => { expect(f.history()).toBe('a\nb\nc\n') } ) - itLinks('deletes a retained copy once a later run finds nothing new in it', async () => { - const f = fixture() - fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') - await f.share() - const pending = join(f.profileHome, 'history.jsonl.orca-profile-merge') - expect(fs.existsSync(`${pending}.offset`)).toBe(true) - fs.appendFileSync(pending, 'late\n') - await f.share() - expect(fs.existsSync(pending)).toBe(true) - await f.share() - expect(fs.existsSync(pending)).toBe(false) - expect(fs.existsSync(`${pending}.offset`)).toBe(false) - expect(f.history()).toBe('p1\nlate\n') - }) - itLinks('drains retained generations in numeric order', async () => { - const f = fixture() - for (const generation of [0, 1, 2, 10, 11]) { - const suffix = generation === 0 ? '' : `-${generation}` - fs.writeFileSync( - join(f.profileHome, `history.jsonl.orca-profile-merge${suffix}`), - `g${generation}\n` - ) - } - await f.share() - expect(f.history()).toBe('g0\ng1\ng2\ng10\ng11\n') - }) - itLinks( - 'drains a copy left by an interrupted share without replaying the shared history', - async () => { - const f = fixture() - fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') - fs.writeFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'd1\nd2\nnew\n') - await f.share() - expect(f.history()).toBe('d1\nd2\nnew\n') - } - ) + itLinks('still links a session tree when its old leftover cannot be read', async () => { const f = fixture() const leftover = join(f.profileHome, 'projects.orca-profile-merge') @@ -231,30 +171,4 @@ describe('Claude profile history sharing', () => { fs.realpathSync(join(f.defaultHome, 'projects')) ) }) - itLinks('still links the profile when an old retained copy cannot be read', async () => { - const f = fixture() - const old = join(f.profileHome, 'history.jsonl.orca-profile-merge') - fs.writeFileSync(old, 'old\n') - fs.chmodSync(old, 0) - fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') - const report = await f.share() - fs.chmodSync(old, 0o600) - expect(report.warnings).toContainEqual(expect.objectContaining({ surface: 'history.jsonl' })) - expect(fs.lstatSync(join(f.profileHome, 'history.jsonl')).isSymbolicLink()).toBe(true) - expect(f.history()).toBe('private\n') - }) - itLinks("pools into the user's own CLAUDE_CONFIG_DIR when they set one", async () => { - const f = fixture() - const userConfigDir = join(f.userHome, 'custom-claude') - fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') - await shareClaudeProfileHistory({ - profileHome: f.profileHome, - userHome: f.userHome, - userConfigDir, - platform: 'linux' - }) - expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe(join(userConfigDir, 'projects')) - expect(fs.readFileSync(join(userConfigDir, 'history.jsonl'), 'utf8')).toBe('p1\n') - expect(fs.readdirSync(f.defaultHome)).toEqual([]) - }) }) diff --git a/src/main/claude-accounts/claude-profile-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts index a0081f7cbdf..269ba3fe991 100644 --- a/src/main/claude-accounts/claude-profile-paths.test.ts +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -10,20 +10,14 @@ import { } from 'node:fs' import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) vi.mock('node:fs', async (original) => { const fs = await original() return { ...fs, unlinkSync: vi.fn(fs.unlinkSync) } }) -import { - describeClaudeProfile, - prepareClaudeProfileDirectory, - readClaudeProfileObject, - readUserClaudeConfigDir, - readUserClaudeConfigDirAsSet -} from './claude-profile-paths' +import { describeClaudeProfile, prepareClaudeProfileDirectory } from './claude-profile-paths' const caseInsensitive = (() => { const dir = mkdtempSync(join(tmpdir(), 'claude-case-probe-')) @@ -88,47 +82,7 @@ describe('Claude profile namespace', () => { ) // A WSL profile's data root is a POSIX path; a Windows temp dir cannot be one. - it.skipIf(process.platform === 'win32')( - 'binds the new namespace to an account and execution target without touching legacy auth', - () => { - const dir = root() - const userHome = root() - const target = { executionHostId: 'runtime:env-1', runtime: 'wsl', distro: 'Ubuntu' } as const - const profile = describeClaudeProfile(dir, 'account-a', target) - expect(profile).toEqual({ - version: 1, - accountId: 'account-a', - target, - home: join(dir, 'claude-profiles/account-a/home') - }) - const markSetUp = prepareClaudeProfileDirectory(dir, profile, userHome) - const marker = join(dir, 'claude-profiles/account-a/profile.json') - expect(existsSync(marker)).toBe(false) - markSetUp() - expect(JSON.parse(readFileSync(marker, 'utf8'))).toEqual({ - version: 1, - accountId: 'account-a', - runtime: 'wsl', - distro: 'Ubuntu' - }) - // Caller-relative host IDs must not affect profile ownership. - prepareClaudeProfileDirectory( - dir, - { ...profile, target: { distro: 'Ubuntu', runtime: 'wsl', executionHostId: 'local' } }, - userHome - ) - expect(() => - prepareClaudeProfileDirectory( - dir, - { ...profile, home: join(dir, 'claude-accounts/account-a/auth') }, - userHome - ) - ).toThrow() - expect(describeClaudeProfile(`${dir}/./x/../`, 'account-a', target).home).toBe(profile.home) - expect(() => describeClaudeProfile(dir, '../escape', target)).toThrow() - expect(() => describeClaudeProfile('C:\\orca', 'a', target)).toThrow() - } - ) + it('refuses a profile whose marker names another account or target, creating nothing', () => { const dir = root() const userHome = root() @@ -217,58 +171,4 @@ describe('Claude profile namespace', () => { prepareClaudeProfileDirectory(dataRoot, describeClaudeProfile(dataRoot, 'a', local), userHome) ).toThrow('separate directories') }) - it("reads the user's own CLAUDE_CONFIG_DIR but never Orca's injected one", () => { - expect(readUserClaudeConfigDir({})).toBeUndefined() - expect(readUserClaudeConfigDir({ CLAUDE_CONFIG_DIR: ' ' })).toBeUndefined() - expect(readUserClaudeConfigDir({ CLAUDE_CONFIG_DIR: '/cfg/claude/' })).toBe( - resolve('/cfg/claude') - ) - expect( - readUserClaudeConfigDir({ - CLAUDE_CONFIG_DIR: '/data/claude-profiles/a/home', - ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/data/claude-profiles/a/home' - }) - ).toBeUndefined() - expect( - readUserClaudeConfigDir({ - CLAUDE_CONFIG_DIR: '/cfg/claude', - ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/data/claude-profiles/a/home' - }) - ).toBe(resolve('/cfg/claude')) - }) - it("keeps the user's own CLAUDE_CONFIG_DIR spelled as set, through an outer Orca too", () => { - expect(readUserClaudeConfigDirAsSet({ CLAUDE_CONFIG_DIR: '/cfg/claude/' })).toBe('/cfg/claude/') - const outer = { - CLAUDE_CONFIG_DIR: '/data/claude-profiles/a/home', - ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/data/claude-profiles/a/home' - } - expect(readUserClaudeConfigDirAsSet(outer)).toBeUndefined() - expect( - readUserClaudeConfigDirAsSet({ ...outer, ORCA_CLAUDE_USER_CONFIG_DIR: 'C:/Claude Config/' }) - ).toBe('C:/Claude Config/') - }) - it("refuses a profile at or around the user's own CLAUDE_CONFIG_DIR", () => { - const userHome = root() - const dataRoot = root() - const profile = describeClaudeProfile(dataRoot, 'a', local) - for (const userConfigDir of [profile.home, dataRoot]) { - expect(() => - prepareClaudeProfileDirectory(dataRoot, profile, userHome, userConfigDir) - ).toThrow('separate directories') - } - expect(existsSync(profile.home)).toBe(false) - }) - it('distinguishes missing from empty, malformed, nonobject and inaccessible JSON', () => { - const file = join(root(), 'state.json') - expect(readClaudeProfileObject(file).kind).toBe('absent') - for (const value of ['', '{', '[]', 'null']) { - writeFileSync(file, value) - expect(readClaudeProfileObject(file).kind).toBe('unavailable') - } - expect(readClaudeProfileObject(join(file, 'child')).kind).toBe('absent') - mkdirSync(join(file, '..', 'dir.json')) - expect(readClaudeProfileObject(join(file, '..', 'dir.json')).kind).toBe('unavailable') - writeFileSync(file, '{"theme":"dark"}') - expect(readClaudeProfileObject(file)).toEqual({ kind: 'present', value: { theme: 'dark' } }) - }) }) diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts index 18b88a07d80..fcd78b20a08 100644 --- a/src/main/claude-accounts/claude-profile-provisioning.test.ts +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -12,14 +12,7 @@ vi.mock('../codex-accounts/fs-utils', async (original) => { const actual = await original() return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } }) -import { writeFileAtomically } from '../codex-accounts/fs-utils' import { provisionClaudeProfile } from './claude-profile-provisioning' - -const USER_HOOK = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } -const ORCA_HOOK = { - matcher: '', - hooks: [{ type: 'command', command: '"$HOME/.orca/agent-hooks/claude-hook.sh"' }] -} const LOGIN = { emailAddress: 'p@example.com' } const roots: string[] = [] function fixture() { @@ -77,34 +70,7 @@ describe('Claude profile refresh from the default home', () => { expect(fs.readFileSync(join(f.profileHome, name, 'mine.md'), 'utf8')).toBe('private') } }) - itLinks( - 'shares personal rules, themes and workflows that later appear in the default home', - async () => { - const f = fixture() - await provision(f) - fs.mkdirSync(join(f.source, 'rules')) - fs.writeFileSync(join(f.source, 'rules/style.md'), 'use tabs') - fs.mkdirSync(join(f.source, 'themes')) - fs.writeFileSync(join(f.source, 'themes/dusk.json'), '{"base":"dark"}') - fs.mkdirSync(join(f.source, 'workflows')) - fs.writeFileSync(join(f.source, 'workflows/review.js'), 'export const meta = {}') - const report = await provision(f) - expect(report.surfaces).toMatchObject({ - rules: 'linked', - themes: 'linked', - workflows: 'linked' - }) - expect(fs.readFileSync(join(f.profileHome, 'rules/style.md'), 'utf8')).toBe('use tabs') - expect(fs.readFileSync(join(f.profileHome, 'themes/dusk.json'), 'utf8')).toBe( - '{"base":"dark"}' - ) - expect(fs.readFileSync(join(f.profileHome, 'workflows/review.js'), 'utf8')).toBe( - 'export const meta = {}' - ) - fs.writeFileSync(join(f.profileHome, 'workflows/saved.js'), 'saved in profile') - expect(fs.readFileSync(join(f.source, 'workflows/saved.js'), 'utf8')).toBe('saved in profile') - } - ) + it('copies top-level files over the profile copy, edited or not', async () => { const f = fixture() const source = join(f.source, 'keybindings.json') @@ -123,33 +89,7 @@ describe('Claude profile refresh from the default home', () => { await provision(f) expect(fs.statSync(join(f.profileHome, 'statusline.sh')).mode & 0o777).toBe(0o755) }) - it('copies settings, proxy key and auth included, and default-home changes win over profile edits', async () => { - const f = fixture() - const settings = { - futureFeature: true, - model: 'a', - hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, - apiKeyHelper: 'helper', - env: { - ANTHROPIC_BASE_URL: 'https://proxy.example.test', - ANTHROPIC_API_KEY: 'proxy-key', - ANTHROPIC_AUTH_TOKEN: 'token', - CLAUDE_CODE_OAUTH_TOKEN: 'oauth', - NORMAL: 'yes' - } - } - f.json(join(f.source, 'settings.json'), settings) - await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual(settings) - f.json(join(f.profileHome, 'settings.json'), { ...settings, model: 'private' }) - f.json(join(f.source, 'settings.json'), { model: 'b' }) - expect((await provision(f)).surfaces['settings.json']).toBe('synced') - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'b' }) - expect((await provision(f)).surfaces['settings.json']).toBe('unchanged') - fs.rmSync(join(f.source, 'settings.json')) - await provision(f) - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({}) - }) + it('keeps settings Claude saved in the account while later default-home edits still arrive', async () => { const f = fixture() const settings = { effortLevel: 'medium', env: { ANTHROPIC_BASE_URL: 'https://a.test' } } @@ -179,18 +119,7 @@ describe('Claude profile refresh from the default home', () => { apiKeyHelper: 'helper' }) }) - it('passes Orca hooks added to the default home later on to the account', async () => { - // WSL accounts get Orca's hooks only through this copy. - const f = fixture() - f.json(join(f.source, 'settings.json'), { theme: 'x' }) - await provision(f) - f.json(join(f.source, 'settings.json'), { theme: 'x', hooks: { Stop: [ORCA_HOOK] } }) - expect((await provision(f)).surfaces['settings.json']).toBe('synced') - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ - theme: 'x', - hooks: { Stop: [ORCA_HOOK] } - }) - }) + itLinks('leaves a linked settings file alone', async () => { const f = fixture() f.json(join(f.source, 'settings.json'), { model: 'a', theme: 'x' }) @@ -262,65 +191,7 @@ describe('Claude profile refresh from the default home', () => { 'PROFILE_CREDENTIAL_BYTES' ) }) - itLinks( - "shares user content, never Claude's runtime state, live sessions or login files", - async () => { - const f = fixture() - const unsharedDirs = [ - 'daemon', - 'jobs', - 'state', - 'sessions', - 'teams', - 'ide', - 'remote-control', - 'shares', - 'uploads', - 'storage-v2', - 'downloads', - 'scratch', - 'agent-memory-local', - '.hidden-dir' - ] - for (const name of [...unsharedDirs, 'skills', 'plugins', 'my-own-dir']) { - fs.mkdirSync(join(f.source, name)) - } - const unsharedFiles = [ - 'daemon.status.json', - '.credentials.json', - '.session_ingress_token', - 'hfi-auth.json', - 'server-sessions.json', - 'active-time.json', - 'computer-use.lock', - 'gh-pr-status-cache.json', - 'loop.md', - 'policy-limits.json', - 'mcp-needs-auth-cache.json', - 'stats-cache.json', - '.last-update-check', - '.config.json', - '.claude.json', - '.claude.json.backup.1' - ] - for (const name of unsharedFiles) { - fs.writeFileSync(join(f.source, name), 'x') - } - fs.writeFileSync(join(f.profileHome, 'server-sessions.json'), 'mine') - await provision(f) - for (const name of ['skills', 'plugins', 'my-own-dir']) { - expect(fs.realpathSync(join(f.profileHome, name))).toBe( - fs.realpathSync(join(f.source, name)) - ) - } - for (const name of [...unsharedDirs, ...unsharedFiles].filter( - (name) => name !== 'server-sessions.json' - )) { - expect(fs.existsSync(join(f.profileHome, name))).toBe(false) - } - expect(fs.readFileSync(join(f.profileHome, 'server-sessions.json'), 'utf8')).toBe('mine') - } - ) + it('merges trusted folders and MCP servers per entry, never untrusting an account folder', async () => { const f = fixture() f.json(join(f.userHome, '.claude.json'), { @@ -367,66 +238,7 @@ describe('Claude profile refresh from the default home', () => { expect((await provision(f)).surfaces['.claude.json']).toBe('merged') expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('dark') }) - it('retries a failed settings copy on the next refresh', async () => { - const f = fixture() - f.json(join(f.source, 'settings.json'), { theme: 'dark' }) - await provision(f) - f.json(join(f.source, 'settings.json'), { theme: 'light' }) - vi.mocked(writeFileAtomically).mockImplementationOnce(() => { - throw Object.assign(new Error('busy'), { code: 'EBUSY' }) - }) - expect((await provision(f)).surfaces['settings.json']).toBe('failed') - expect((await provision(f)).surfaces['settings.json']).toBe('synced') - expect(f.read(join(f.profileHome, 'settings.json')).theme).toBe('light') - }) - it('imports the personal CLAUDE.md instead of copying it, so Claude loads it once', async () => { - const f = fixture() - await provision(f) - expect(fs.existsSync(join(f.profileHome, 'CLAUDE.md'))).toBe(false) - fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'personal instructions') - expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') - expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('@~/.claude/CLAUDE.md\n') - fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') - expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') - fs.writeFileSync(join(f.profileHome, 'CLAUDE.md'), 'edited in the profile') - expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') - }) - itLinks( - "shares from the user's own CLAUDE_CONFIG_DIR, copying its CLAUDE.md and state", - async () => { - const f = fixture() - const userConfigDir = join(f.userHome, 'custom-claude') - fs.mkdirSync(join(userConfigDir, 'skills'), { recursive: true }) - fs.writeFileSync(join(userConfigDir, 'CLAUDE.md'), 'custom instructions') - f.json(join(userConfigDir, 'settings.json'), { model: 'custom' }) - f.json(join(userConfigDir, '.claude.json'), { theme: 'custom' }) - f.json(join(f.userHome, '.claude.json'), { theme: 'home' }) - f.json(join(f.profileHome, '.claude.json'), { userID: 'p', oauthAccount: LOGIN }) - await provisionClaudeProfile({ ...f, userConfigDir, platform: 'linux' }) - expect(fs.realpathSync(join(f.profileHome, 'skills'))).toBe(join(userConfigDir, 'skills')) - expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('custom instructions') - expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'custom' }) - expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('custom') - } - ) - itLinks("links to the default home's own entry, not where a user link of it points", async () => { - const f = fixture() - fs.mkdirSync(join(f.root, 'dotfiles-skills')) - fs.symlinkSync(join(f.root, 'dotfiles-skills'), join(f.source, 'skills')) - expect((await provision(f)).surfaces.skills).toBe('linked') - expect(fs.readlinkSync(join(f.profileHome, 'skills'))).toBe(join(f.source, 'skills')) - expect((await provision(f)).surfaces.skills).toBe('unchanged') - }) - it('uses Windows junctions through platform injection (native Windows remains unverified)', async () => { - const f = fixture() - fs.mkdirSync(join(f.source, 'skills')) - await provisionClaudeProfile({ ...f, platform: 'win32' }) - expect(fs.symlinkSync).toHaveBeenCalledWith( - join(f.source, 'skills'), - join(f.profileHome, 'skills'), - 'junction' - ) - }) + it('leaves malformed profile state unchanged', async () => { const f = fixture() f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index b9765439729..b5349098f59 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -1,4 +1,3 @@ -import { runProcess } from '@orca/process-host' import { spawnSync } from 'node:child_process' import { chmodSync, @@ -8,7 +7,6 @@ import { readFileSync, rmSync, statSync, - symlinkSync, utimesSync, writeFileSync } from 'node:fs' @@ -23,10 +21,7 @@ import { prepareClaudeProfileDirectory, type ClaudeProfileDescriptor } from './claude-profile-paths' -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../shared/claude-profile-routing' +import { CLAUDE_PROFILE_SETUP_FAILED_MESSAGE } from '../../shared/claude-profile-routing' import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' import { @@ -162,49 +157,6 @@ function claudeShellProbe(root: string): (env: Record, rc?: stri } describe('ClaudeProfileRouter', () => { - it.each([ - ['launcher 0.1.0\n2.1.310 (Claude Code)', '2.1.310'], - ['11.17.0', undefined] - ] as const)( - 'passes identified version or unknown to enabled profile setup: %s', - async (stdout, version) => { - const f = fixture() - f.settings.agentStatusHooksEnabled = true - command.path = join(f.root, 'claude') - vi.mocked(runProcess).mockResolvedValue({ - stdout, - stderr: '', - code: 0, - signal: null, - timedOut: false - }) - const runSetup = vi.fn(async () => ({ - outcome: 'prepared' as const, - warnings: [], - surfaces: {} - })) - const router = new ClaudeProfileRouter({ - getSettings: () => f.settings, - dataRoot: f.dataRoot, - userHome: f.userHome, - env: {}, - runSetup - }) - await router.prepareAccount('a') - await router.prepareAccount('b') - expect(runSetup).toHaveBeenCalledTimes(2) - for (const accountId of ['a', 'b']) { - expect(runSetup).toHaveBeenCalledWith( - expect.objectContaining({ - profile: expect.objectContaining({ accountId }), - hooks: true, - claudeVersion: version - }) - ) - } - } - ) - it('runs an account with no login of its own on System default while that is signed in to it', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const f = fixture() @@ -243,19 +195,6 @@ describe('ClaudeProfileRouter', () => { await expect(claudeRoutedHomeAsync(f.router)).resolves.toBe(f.home('a')) }) - it('tells whether a terminal opened before routing runs another account than the selected one', () => { - const f = fixture() - expect(f.router.systemDefaultRunsAnotherAccount()).toBe(true) - signIn(f.userHome, 'a@example.test') - expect(f.router.systemDefaultRunsAnotherAccount()).toBe(false) - const other = fixture() - signIn(other.userHome, 'a@example.test') - signIn(other.home('a'), 'someone-else@example.test') - expect(other.router.systemDefaultRunsAnotherAccount()).toBe(true) - f.settings.activeClaudeManagedAccountId = null - expect(f.router.systemDefaultRunsAnotherAccount()).toBeNull() - }) - it('keeps an account on its own folder when System default is signed in to another email, or out', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const f = fixture() @@ -278,54 +217,6 @@ describe('ClaudeProfileRouter', () => { await expect(signedOutLaunch).resolves.toMatchObject({ configDir: f.home('a') }) }) - it('never covers an account saved for another organization of the same email', () => { - const f = fixture() - f.settings.claudeManagedAccounts = f.settings.claudeManagedAccounts.map((account) => ({ - ...account, - organizationUuid: account.id === 'a' ? 'org-a' : null - })) - signIn(f.userHome, 'a@example.test', 'org-b') - expect(f.router.coveredBySystemDefault('a')).toBe(false) - expect(f.router.systemDefaultRunsAnotherAccount()).toBe(true) - signIn(f.userHome, 'a@example.test', 'org-a') - expect(f.router.coveredBySystemDefault('a')).toBe(true) - signIn(f.userHome, 'b@example.test', 'org-b') - expect(f.router.coveredBySystemDefault('b')).toBe(true) - }) - - it("compares against the user's own CLAUDE_CONFIG_DIR login when they set one", async () => { - const own = resolve(mkdtempSync(join(tmpdir(), 'claude-router-own-'))) - roots.push(own) - const f = fixture({ CLAUDE_CONFIG_DIR: own }) - signIn(f.userHome, 'b@example.test') - signIn(own, 'a@example.test') - await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: own }) - expect(f.setup.calls).toBe(0) - }) - - it('prepares an account folder for sign-in only when setup succeeds', async () => { - const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/custom/claude') }) - expect(f.router.accountHome('b')).toBe(f.home('b')) - expect(f.router.userConfigDir()).toBe(resolve('/custom/claude')) - const prepared = f.router.prepareAccount('b') - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - f.setup.settle() - await expect(prepared).resolves.toBe(f.home('b')) - f.setup.outcome = 'refused' - const refused = f.router.prepareAccount('b') - await vi.waitFor(() => expect(f.setup.calls).toBe(2)) - f.setup.settle() - await expect(refused).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - }) - - it("reads an older Orca's copy-based switching from the System default snapshot it left", () => { - const f = fixture() - expect(f.router.copiedLoginIntoSystemDefault()).toBe(false) - mkdirSync(join(f.dataRoot, 'claude-runtime-auth'), { recursive: true }) - writeFileSync(join(f.dataRoot, 'claude-runtime-auth', 'system-default-auth.json'), '{}') - expect(f.router.copiedLoginIntoSystemDefault()).toBe(true) - }) - it('publishes the selected folder, System default as empty, and no file without accounts', async () => { const f = fixture() mkdirSync(f.home('a'), { recursive: true }) @@ -342,25 +233,6 @@ describe('ClaudeProfileRouter', () => { expect(existsSync(f.router.pointerPath)).toBe(false) }) - it('makes a launch wait for a first setup that never ran or is still running, reusing it', async () => { - const f = fixture() - mkdirSync(f.home('a'), { recursive: true }) - let launched = false - const launch = f.router.prepareLaunch().then((prepared) => { - launched = true - return prepared - }) - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - await new Promise((resolve) => setTimeout(resolve, 10)) - expect(launched).toBe(false) - const second = f.router.prepareLaunch() - f.setup.outcome = 'refused' - f.setup.settle() - await expect(launch).rejects.toMatchObject({ reason: 'claudeAccountSetupFailed' }) - await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - expect(f.setup.calls).toBe(1) - }) - it('refreshes a set-up folder before each launch, which never fails or hangs on it', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const f = fixture() @@ -450,34 +322,6 @@ describe('ClaudeProfileRouter', () => { await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) }) - it("sets up a pre-update account's missing folder without a login, for Claude's own first run", async () => { - const f = fixture() - f.settings.activeClaudeManagedAccountId = 'b' - expect(() => f.router.preparation()).toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - expect(() => f.router.routedHome()).toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - expect(f.router.routedHome('identity')).toBe(f.home('b')) - await expect(claudeRoutedHomeAsync(f.router)).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - await expect(claudeRoutedHomeAsync(f.router, 'identity')).resolves.toBe(f.home('b')) - expect(f.router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath }) - f.router.publish() - expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - expect(existsSync(f.home('b'))).toBe(true) - expect(existsSync(join(f.home('b'), '.claude.json'))).toBe(false) - const launch = f.router.prepareLaunch() - f.setup.settle() - await expect(launch).resolves.toMatchObject({ configDir: f.home('b') }) - expect(f.setup.calls).toBe(1) - }) - - it('makes a launch set up a missing folder first', async () => { - const f = fixture() - const launch = f.router.prepareLaunch() - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - f.setup.settle() - await expect(launch).resolves.toMatchObject({ configDir: f.home('a') }) - }) - it('requires concurrent launches to finish rebuilding a home whose marker survived', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const f = fixture() @@ -512,74 +356,6 @@ describe('ClaudeProfileRouter', () => { }) }) - it('refuses required setup when a file occupies the account home', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const f = fixture() - mkdirSync(join(f.home('a'), '..'), { recursive: true }) - prepareClaudeProfileDirectory( - f.dataRoot, - describeClaudeProfile(f.dataRoot, 'a', { - executionHostId: 'local', - runtime: 'host' - }), - f.userHome - )() - rmSync(f.home('a'), { recursive: true }) - writeFileSync(f.home('a'), 'owned-file') - const failed = expect(f.router.prepareLaunch()).rejects.toMatchObject({ - reason: 'claudeAccountSetupFailed' - }) - await vi.advanceTimersByTimeAsync(1_000) - await failed - }) - - it('injects the account with its twin, and nothing over the user’s own System default', () => { - const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/user/own') }) - mkdirSync(f.home('a'), { recursive: true }) - expect(f.router.preparation()).toMatchObject({ - configDir: f.home('a'), - envPatch: { - ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, - CLAUDE_CONFIG_DIR: f.home('a'), - ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a') - } - }) - f.settings.activeClaudeManagedAccountId = null - expect(f.router.preparation()).toMatchObject({ - configDir: resolve('/user/own'), - envPatch: { ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath } - }) - expect(f.router.preparation().envPatch).not.toHaveProperty('CLAUDE_CONFIG_DIR') - }) - - it('carries the user’s own value exactly as set, since Claude keys its login by that string', () => { - const f = fixture({ CLAUDE_CONFIG_DIR: 'C:/Claude Config/' }) - f.settings.activeClaudeManagedAccountId = null - expect(f.router.terminalEnv()).toMatchObject({ - ORCA_CLAUDE_USER_CONFIG_DIR: 'C:/Claude Config/' - }) - }) - - it('carries the user’s own value in every host terminal, beside an injected one too', () => { - const f = fixture({ CLAUDE_CONFIG_DIR: resolve('/user/own') }) - mkdirSync(f.home('a'), { recursive: true }) - expect(f.router.terminalEnv()).toEqual({ - ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, - CLAUDE_CONFIG_DIR: f.home('a'), - ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a'), - ORCA_CLAUDE_USER_CONFIG_DIR: resolve('/user/own') - }) - expect(f.router.preparation().envPatch).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') - f.settings.activeClaudeManagedAccountId = null - expect(f.router.terminalEnv()).toEqual({ - ORCA_CLAUDE_PROFILE_POINTER: f.router.pointerPath, - ORCA_CLAUDE_USER_CONFIG_DIR: resolve('/user/own') - }) - const none = fixture() - mkdirSync(none.home('a'), { recursive: true }) - expect(none.router.terminalEnv()).not.toHaveProperty('ORCA_CLAUDE_USER_CONFIG_DIR') - }) - // These probes run the POSIX shell function under Bash. it.skipIf(process.platform === 'win32')( 'gives an account terminal back the inherited CLAUDE_CONFIG_DIR, or none, on System default', @@ -635,71 +411,7 @@ describe('ClaudeProfileRouter', () => { expect(existsSync(join(f.dataRoot, 'claude-profiles', 'a'))).toBe(false) }) - it("gives a pane only the pointer until the login shell's env arrives, then rewrites it", async () => { - const f = fixture() - mkdirSync(f.home('a'), { recursive: true }) - signIn(f.home('a'), 'a@example.test') - const router = new ClaudeProfileRouter({ - getSettings: () => f.settings, - dataRoot: f.dataRoot, - userHome: f.userHome, - runSetup: async () => ({ outcome: 'prepared', warnings: [], surfaces: {} }) - }) - expect(router.terminalEnv()).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: router.pointerPath }) - await vi.waitFor(() => expect(readFileSync(router.pointerPath, 'utf8')).toBe(f.home('a'))) - expect(router.terminalEnv()).toMatchObject({ CLAUDE_CONFIG_DIR: f.home('a') }) - }) - - it('takes System default from the login shell, which a Dock launch does not inherit', async () => { - const f = fixture() - f.settings.activeClaudeManagedAccountId = null - const router = new ClaudeProfileRouter({ - getSettings: () => f.settings, - dataRoot: f.dataRoot, - userHome: f.userHome - }) - await expect(router.prepareLaunch()).resolves.toMatchObject({ - configDir: resolve('/shell/own'), - provenance: 'system' - }) - }) - - it('treats a CLAUDE_CONFIG_DIR an outer Orca injected as not the user’s', () => { - const f = fixture({ - CLAUDE_CONFIG_DIR: '/outer/profile', - ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile' - }) - expect(f.router.systemDefaultHome()).toBe(join(f.userHome, '.claude')) - const carried = fixture({ - CLAUDE_CONFIG_DIR: '/outer/profile', - ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/outer/profile', - ORCA_CLAUDE_USER_CONFIG_DIR: '/user/own' - }) - expect(carried.router.systemDefaultHome()).toBe(resolve('/user/own')) - }) - // Creating real symlinks requires additional Windows privileges. - it.skipIf(process.platform === 'win32')( - 'lists account history the System default cannot see, skipping linked folders', - () => { - const f = fixture() - expect(claudeProfileHistoryDirs('projects')).toEqual([]) - installClaudeProfileRouter(f.router) - mkdirSync(join(f.home('a'), 'projects'), { recursive: true }) - mkdirSync(f.home('b'), { recursive: true }) - symlinkSync(join(f.userHome, '.claude'), join(f.home('b'), 'projects')) - writeFileSync(join(f.dataRoot, 'claude-profiles', 'selected-host'), '') - expect(claudeProfileHistoryDirs('projects')).toEqual([join(f.home('a'), 'projects')]) - } - ) - - it('lists history under the CLAUDE_CONFIG_DIR only the login shell exports', () => { - const configDir = join(tmpdir(), `claude-router-own-${process.pid}`) - roots.push(configDir) - mkdirSync(join(configDir, 'projects'), { recursive: true }) - installClaudeProfileRouter(fixture({ CLAUDE_CONFIG_DIR: configDir }).router) - expect(claudeProfileHistoryDirs('projects')).toEqual([join(configDir, 'projects')]) - }) it('lists history under a wrapper CLAUDE_CONFIG_DIR seen in a hook, after restart too', () => { const f = fixture() @@ -713,29 +425,4 @@ describe('ClaudeProfileRouter', () => { installClaudeObservedConfigDirs(observe()) expect(claudeProfileHistoryDirs('projects')).toEqual([projects]) }) - - it("lists history under the CLAUDE_CONFIG_DIR set in Orca's Claude launch environment", () => { - const f = fixture() - const configDir = join(f.root, '.claude-settings') - mkdirSync(join(configDir, 'projects'), { recursive: true }) - const agentDefaultEnv = { claude: { CLAUDE_CONFIG_DIR: configDir } } - installClaudeObservedConfigDirs( - new ClaudeObservedConfigDirs(join(f.root, 'observed.json'), () => ({ agentDefaultEnv })) - ) - expect(claudeProfileHistoryDirs('projects')).toEqual([join(configDir, 'projects')]) - }) - - it('ignores non-transcript paths and keeps at most 32 observed folders', () => { - const f = fixture() - const dirs = new ClaudeObservedConfigDirs(join(f.root, 'observed.json'), () => ({})) - mkdirSync(join(f.root, 'other', 'sessions', '-proj'), { recursive: true }) - dirs.observeTranscriptPath(join(f.root, 'other', 'sessions', '-proj', 'id.jsonl')) - expect(dirs.list()).toEqual([]) - for (let index = 0; index < 33; index += 1) { - mkdirSync(join(f.root, `c${index}`, 'projects', '-proj'), { recursive: true }) - dirs.observeTranscriptPath(join(f.root, `c${index}`, 'projects', '-proj', 'id.jsonl')) - } - expect(dirs.list()).toHaveLength(32) - expect(dirs.list()[0]).toBe(join(f.root, 'c32')) - }) }) diff --git a/src/main/claude-accounts/claude-profile-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts index f6aa41c958c..a1308315d42 100644 --- a/src/main/claude-accounts/claude-profile-setup.test.ts +++ b/src/main/claude-accounts/claude-profile-setup.test.ts @@ -153,37 +153,7 @@ describe('Claude account profile setup', () => { runtime: 'host' }) }) - itLinks("brings the user's later hooks into an account set up while they had none", async () => { - const f = fixture() - const settings = join(f.defaultHome, 'settings.json') - writeFileSync(settings, '{"model":"opus"}') - await f.setup() - const home = join(f.dataRoot, 'claude-profiles/a/home') - const read = (file: string) => JSON.parse(readFileSync(file, 'utf8')) - const orca = read(join(home, 'settings.json')).hooks - expect(orca).toBeDefined() - const mine = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } - writeFileSync(settings, JSON.stringify({ model: 'opus', hooks: { Notification: [mine] } })) - expect((await f.setup()).warnings).toEqual([]) - const hooks = read(join(home, 'settings.json')).hooks - expect(hooks.Notification).toContainEqual(mine) - expect(hooks.Stop).toEqual(orca.Stop) - }) - itLinks('refuses another account in the same slot without creating anything', async () => { - const f = fixture() - await f.setup() - writeFileSync( - join(f.dataRoot, 'claude-profiles/a/profile.json'), - JSON.stringify({ version: 1, accountId: 'b', runtime: 'host' }) - ) - rmSync(join(f.dataRoot, 'claude-profiles/a/home'), { recursive: true }) - const report = await f.setup() - expect(report.outcome).toBe('refused') - expect(report.warnings).toEqual([ - expect.objectContaining({ surface: 'profile', code: 'invalid-profile' }) - ]) - expect(existsSync(join(f.dataRoot, 'claude-profiles/a/home'))).toBe(false) - }) + itLinks('refuses a data root linked into the default home before writing there', async () => { const f = fixture() mkdirSync(join(f.defaultHome, 'inner')) @@ -191,25 +161,4 @@ describe('Claude account profile setup', () => { expect((await f.setup()).outcome).toBe('refused') expect(existsSync(join(f.defaultHome, 'inner', 'a'))).toBe(false) }) - itLinks('reports skipped and failed hook installs without failing the rest', async () => { - const f = fixture() - const skipped = await provisionClaudeAccountProfile({ - dataRoot: f.dataRoot, - profile: describeClaudeProfile(f.dataRoot, 'a', local), - userHome: state.home, - installHooks: null, - platform: 'linux' - }) - expect(skipped.surfaces.hooks).toBe('absent') - const settings = join(f.dataRoot, 'claude-profiles/a/home/settings.json') - writeFileSync(join(f.root, 'bad.json'), '{bad') - rmSync(settings, { force: true }) - symlinkSync(join(f.root, 'bad.json'), settings) - const failed = await f.setup() - expect(failed.surfaces.hooks).toBe('failed') - expect(failed.surfaces.projects).toBe('unchanged') - expect(failed.warnings).toContainEqual( - expect.objectContaining({ surface: 'hooks', code: 'failed' }) - ) - }) }) diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index 87896d6eb69..cb622b5601c 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -50,15 +50,10 @@ vi.mock('../wsl/execution/wsl-runner', () => ({ return { code: result.status, stdout: result.stdout, stderr: result.stderr, timedOut: false } } })) - -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../shared/claude-profile-routing' +import { CLAUDE_PROFILE_SETUP_FAILED_MESSAGE } from '../../shared/claude-profile-routing' import type { ClaudeProfileRouterSettings } from './claude-profile-router' import { prepareClaudeProfileDirectory } from './claude-profile-paths' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' -import { applyClaudeEnvPatch } from './environment' import { wslClaudeProfile } from './claude-profile-wsl-paths' // Guest emulation runs POSIX scripts and the Linux Node bundle. @@ -113,37 +108,6 @@ function fixture() { } describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { - it('names why a silent guest command failed instead of a bare "command failed"', async () => { - const f = fixture() - guest.failure = { code: 0xffffffff, stdout: 'Wsl/Service/0x80072747\r\n' } - await expect(f.router.publish('Ubuntu')).rejects.toThrow( - 'WSL Ubuntu: wsl.exe: Wsl/Service/0x80072747' - ) - guest.failure = { code: 3, stdout: '' } - await expect(f.router.publish('Ubuntu')).rejects.toThrow('WSL Ubuntu: exited with code 3') - }) - - it('never publishes at startup to a distro a list still names while it stops (E9)', async () => { - const f = fixture() - gate.blocked.mockResolvedValueOnce({ distro: 'Ubuntu', state: 'stopped' }) - expect(await f.router.runningDistros()).toEqual([]) - expect(gate.blocked).toHaveBeenCalledWith('', 'Ubuntu') - }) - - it('publishes and launches the saved account across distro casing without changing guest spelling', async () => { - const f = fixture() - await f.router.publish('ubuntu') - expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) - const launch = await f.router.prepareLaunch('ubuntu') - expect(launch).toMatchObject({ - wslDistro: 'ubuntu', - envPatch: { CLAUDE_CONFIG_DIR: f.profileHome } - }) - f.settings.claudeManagedAccounts[0].wslDistro = 'ubuntu' - expect(await f.router.runningDistros()).toEqual(['Ubuntu']) - expect((await f.router.preparation('Ubuntu')).envPatch.CLAUDE_CONFIG_DIR).toBe(f.profileHome) - }) - it('writes the guest pointer per build, sets up even a missing folder, and removes it with the last account', async () => { const f = fixture() await f.router.publish('Ubuntu') @@ -159,49 +123,6 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(existsSync(f.pointer)).toBe(false) }) - it('sets up a missing folder, waits for a first setup never run or still running, then launches at once', async () => { - const f = fixture() - await expect(f.router.preparation('Ubuntu')).rejects.toThrow(CLAUDE_PROFILE_MISSING_MESSAGE) - f.setup.fail = true - let release = () => {} - f.setup.gate = new Promise((resolve) => (release = resolve)) - const launch = f.router.prepareLaunch('Ubuntu') - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - const second = f.router.prepareLaunch('Ubuntu') - await new Promise((resolve) => setTimeout(resolve, 50)) - release() - await expect(launch).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - await expect(second).rejects.toThrow(CLAUDE_PROFILE_SETUP_FAILED_MESSAGE) - expect(f.setup.calls).toBe(1) - - f.setup.fail = false - const prepared = await f.router.prepareLaunch('Ubuntu') - expect(prepared).toMatchObject({ - configDir: f.profileHome, - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: f.profileHome, - envPatch: { - ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca-dev', - CLAUDE_CONFIG_DIR: f.profileHome, - ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.profileHome - } - }) - expect(f.setup.calls).toBe(2) - f.setup.gate = new Promise(() => {}) - await f.router.prepareLaunch('Ubuntu') - expect(f.setup.calls).toBe(3) - }) - - it('reads usage from the same guest folder a launch (local or WSL-host) runs in', async () => { - const f = fixture() - const launch = await f.router.prepareLaunch('Ubuntu') - const usage = await f.router.accountUsagePreparation('Ubuntu', 'a') - const poll = await f.router.preparation('Ubuntu') - expect(usage.wslLinuxConfigDir).toBe(launch.envPatch.CLAUDE_CONFIG_DIR) - expect(poll.wslLinuxConfigDir).toBe(launch.envPatch.CLAUDE_CONFIG_DIR) - }) - it('makes a launch redo a first setup that was cut off after its ownership gate', async () => { const f = fixture() mkdirSync(f.profileHome, { recursive: true }) @@ -223,26 +144,6 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(f.setup.calls).toBe(1) }) - it('never makes a launch wait on, or fail with, a re-run of a set-up folder', async () => { - const f = fixture() - mkdirSync(f.profileHome, { recursive: true }) - writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') - f.setup.fail = true - let release = () => {} - f.setup.gate = new Promise((resolve) => (release = resolve)) - await f.router.publish('Ubuntu') - await vi.waitFor(() => expect(f.setup.calls).toBe(1)) - await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ - configDir: f.profileHome - }) - release() - await new Promise((resolve) => setTimeout(resolve, 10)) - await expect(f.router.prepareLaunch('Ubuntu')).resolves.toMatchObject({ - configDir: f.profileHome - }) - expect(f.setup.calls).toBe(2) - }) - it('sets up a new account folder for sign-in and deletes it without following its links', async () => { const f = fixture() const sharedHistory = join(guest.home, '.claude', 'projects') @@ -282,15 +183,6 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(existsSync(join(f.profileHome, '..'))).toBe(false) }) - it('writes a missing guest pointer before a launch returns', async () => { - const f = fixture() - mkdirSync(f.profileHome, { recursive: true }) - writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') - expect(existsSync(f.pointer)).toBe(false) - await f.router.prepareLaunch('Ubuntu') - expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) - }) - it('a launch waiting on setup leaves a selection made meanwhile in the pointer', async () => { const f = fixture() mkdirSync(f.profileHome, { recursive: true }) @@ -309,16 +201,6 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { expect(readFileSync(f.pointer, 'utf8')).toBe(homeB) }) - it('overwrites a guest pointer that names another account before a launch returns', async () => { - const f = fixture() - mkdirSync(f.profileHome, { recursive: true }) - writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') - mkdirSync(join(f.pointer, '..'), { recursive: true }) - writeFileSync(f.pointer, join(f.profileHome, '../../b/home')) - await f.router.prepareLaunch('Ubuntu') - expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome) - }) - it("runs an account with no login of its own on the guest's ~/.claude while that is signed in to it", async () => { const f = fixture() mkdirSync(f.profileHome, { recursive: true }) @@ -359,29 +241,6 @@ describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => { wslLinuxConfigDir: f.profileHome }) }) - - it("keeps a shell proxy's key with its address on WSL launches, account or System default", async () => { - const f = fixture() - mkdirSync(f.profileHome, { recursive: true }) - writeFileSync(join(f.profileHome, '..', 'profile.json'), '{}') - const shell = { ANTHROPIC_BASE_URL: 'https://proxy.example.test', ANTHROPIC_API_KEY: 'k' } - for (const selected of ['a', null]) { - f.wsl.Ubuntu = selected - const prepared = await f.router.prepareLaunch('Ubuntu') - expect(applyClaudeEnvPatch({ ...shell }, prepared.envPatch)).toMatchObject(shell) - } - }) - - it('launches System default from the guest ~/.claude with no account env', async () => { - const f = fixture() - f.wsl.Ubuntu = null - const prepared = await f.router.preparation('Ubuntu') - expect(prepared.wslLinuxConfigDir).toBe(join(guest.home, '.claude')) - expect(prepared.envPatch).toEqual({ - ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca-dev' - }) - expect(await f.router.runningDistros()).toEqual(['Ubuntu']) - }) }) it.skipIf(!posixHost)( diff --git a/src/main/claude-usage/persisted-source-authority.test.ts b/src/main/claude-usage/persisted-source-authority.test.ts index 8059fc983ac..394ca9693be 100644 --- a/src/main/claude-usage/persisted-source-authority.test.ts +++ b/src/main/claude-usage/persisted-source-authority.test.ts @@ -12,7 +12,6 @@ import { splitClaudeUsageCacheFile, writeClaudeUsageSourceCache } from './persisted-source-cache' -import { sealUsageCacheJson } from '../usage/usage-cache-json-integrity' import { compressClaudeUsageSourceText, decodeClaudeUsageSourceText @@ -183,56 +182,6 @@ it('rejects same-stat direct scanner corruption without a raw-envelope proof', a expect(rebuilt.sessions[0]?.turnCount).toBe(1) }) -it.each(['missing', 'null', 'number', 'pretty', 'domain'] as const)( - 'starts cold for %s source framing', - async (corruption) => { - const original = await fixture() - await writeClaudeUsageSourceCache(ref(), [original]) - const text = await readFile(sourcePath, 'utf8') - const saved = JSON.parse(text) - if (corruption === 'missing') { - delete saved.usageIntegrity - } - if (corruption === 'null') { - saved.usageIntegrity = null - } - if (corruption === 'number') { - saved.usageIntegrity = 7 - } - const damaged = - corruption === 'domain' - ? serializeClaudeUsageReport(state(original)) - : JSON.stringify(saved, null, corruption === 'pretty' ? 2 : undefined) - await writeFile(sourcePath, damaged) - - expect((await readClaudeUsageSourceCache(ref())).sources).toEqual([]) - } -) - -it('does not present a corrupt future completion as fresh report history', async () => { - const original = await fixture() - const saved = JSON.parse(serializeClaudeUsageReport(state(original))) - saved.scanState.lastScanCompletedAt = Date.now() + 86_400_000 - - const rejected = await parseClaudeUsageReport(JSON.stringify(saved)) - - expect(rejected.sessions).toEqual([]) - expect(rejected.scanState).toMatchObject({ enabled: false, lastScanCompletedAt: null }) - expect(rejected.scanState.lastScanError).toContain('could not be validated') -}) - -it('retains genuine unsigned schema6 reports without promoting their lineage', async () => { - const original = await fixture(false) - const legacy = state(original, 6) - const text = serializeClaudeUsageReport(legacy) - - expect(text).not.toContain('usageIntegrity') - expect(await parseClaudeUsageReport(text)).toMatchObject({ - schemaVersion: 6, - sessions: original.sessions - }) -}) - it('does not seal arbitrary unprotected schema7 inline totals during migration', async () => { const original = await fixture(false) const cacheFile = join(directory, 'usage.json') @@ -249,18 +198,3 @@ it('does not seal arbitrary unprotected schema7 inline totals during migration', expect(report.scanState).toMatchObject({ enabled: false, lastScanCompletedAt: null }) expect(report.scanState.lastScanError).toContain('could not be validated') }) - -it('rejects a source envelope stamped for an unsupported producer schema', async () => { - const original = await fixture() - const material = JSON.stringify({ - schemaVersion: 6, - worktreeFingerprint: '[]', - sources: [original] - }) - await writeFile(sourcePath, sealUsageCacheJson(material, 'claude-usage-sources-v1')) - expect((await readClaudeUsageSourceCache(ref())).sources).toEqual([]) -}) - -it('rejects empty-object integrity material rather than emitting invalid JSON', () => { - expect(() => sealUsageCacheJson('{}', 'test')).toThrow('requires a JSON object') -}) diff --git a/src/main/claude-usage/persisted-token-columns.test.ts b/src/main/claude-usage/persisted-token-columns.test.ts deleted file mode 100644 index f6da90edae8..00000000000 --- a/src/main/claude-usage/persisted-token-columns.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { expect, it } from 'vitest' -import { - decodeClaudeUsagePersistedFile, - encodeClaudeUsagePersistedFiles, - encodeClaudeUsagePersistedState -} from './persisted-token-columns' -import { buildClaudeUsageProjectionIntegrity } from './transcript-projection-integrity' -import { hasClaudeUsageResumeProjection } from './transcript-resume-projection' -import { aggregateClaudeUsage } from './usage-aggregation' -import type { - ClaudeUsageAttributedTurn, - ClaudeUsagePersistedFile, - ClaudeUsagePersistedState, - ClaudeUsageTokenMaxima -} from './types' - -function fixture(rows: ClaudeUsageTokenMaxima[]): ClaudeUsagePersistedFile { - const turns: ClaudeUsageAttributedTurn[] = [] - const routes = [0, 1].filter((index) => rows.some((row) => row[5] === index)) - const order = new Map() - for (const row of rows) { - if (row[5] === null) { - continue - } - const sessionId = `session-${row[5]}` - const projectKey = `project-${row[5]}` - order.set(sessionId, [projectKey]) - turns.push({ - sessionId, - timestamp: '2026-10-09T12:00:00.000Z', - model: 'model', - cwd: null, - gitBranch: null, - inputTokens: row[0], - outputTokens: row[1], - cacheReadTokens: row[2], - cacheWriteTokens: row[3], - cacheWrite1hTokens: row[4], - day: '2026-10-09', - projectKey, - projectLabel: projectKey, - repoId: null, - worktreeId: null - }) - } - const file: ClaudeUsagePersistedFile = { - path: join(tmpdir(), 'claude-token-columns.jsonl'), - mtimeMs: 1, - size: 20_000, - lineCount: rows.length + 1, - physicalFileId: '1:2', - ctimeMs: 2, - ...aggregateClaudeUsage(turns), - ownedDedupeKeys: rows.map((_, index) => `key-${index}`), - hasDeferredClaims: true, - parseResumeState: { - parsedBytes: 20_000, - lineCount: rows.length + 1, - boundaryDigest: 'boundary', - headDigest: 'head', - physicalFileId: '1:2', - ownedTokenMaxima: rows, - projections: routes.map((index) => ({ - sessionId: `session-${index}`, - day: '2026-10-09', - model: 'model', - projectKey: `project-${index}` - })), - encounterOrder: [...order].map(([sessionId, projectKeys]) => ({ sessionId, projectKeys })) - } - } - if (!file.parseResumeState) { - throw new Error('Expected a checkpoint fixture.') - } - file.parseResumeState.projectionIntegrity = buildClaudeUsageProjectionIntegrity(file) - return file -} - -function savedState(file: ClaudeUsagePersistedFile): ClaudeUsagePersistedState { - return { - schemaVersion: 7, - worktreeFingerprint: '[]', - processedFiles: [file], - sessions: file.sessions, - dailyAggregates: file.dailyAggregates, - scanState: { - enabled: true, - lastScanStartedAt: 1, - lastScanCompletedAt: 2, - lastScanError: null - } - } -} - -function pack(file: ClaudeUsagePersistedFile): ClaudeUsagePersistedFile { - const state: ClaudeUsagePersistedState = JSON.parse( - JSON.stringify(encodeClaudeUsagePersistedState(savedState(file))) - ) - const packed = state.processedFiles[0] - if (!packed) { - throw new Error('Expected an encoded file.') - } - return packed -} - -function columns(file: ClaudeUsagePersistedFile): unknown[] { - const state = file.parseResumeState - if (!state || !('ownedTokenColumns' in state) || !Array.isArray(state.ownedTokenColumns)) { - throw new Error('Expected six encoded columns.') - } - return state.ownedTokenColumns -} - -function decode(file: ClaudeUsagePersistedFile): { - file: ClaudeUsagePersistedFile - yields: number -} { - const iterator = decodeClaudeUsagePersistedFile(file) - let yields = 0 - let step = iterator.next() - while (!step.done) { - yields++ - step = iterator.next() - } - return { file: step.value, yields } -} - -it('packs uniform columns without mutating tuples, sharing decoded rows or retaining disk fields', () => { - const original = fixture([ - [100, 10, 0, 20, 5, 0], - [100, 10, 0, 20, 5, 0] - ]) - const before = structuredClone(original) - const packed = pack(original) - expect(columns(packed)).toEqual([100, 10, 0, 20, 5, 0]) - expect(packed.parseResumeState).toHaveProperty('tokenCodecVersion', 1) - expect(packed.parseResumeState).not.toHaveProperty('ownedTokenMaxima') - expect(original).toEqual(before) - const decoded = decode(packed).file - expect(decoded).toEqual(original) - expect(decoded.parseResumeState).not.toHaveProperty('ownedTokenColumns') - expect(decoded.parseResumeState).not.toHaveProperty('tokenCodecVersion') - expect(hasClaudeUsageResumeProjection(decoded)).toBe(true) - const first = decoded.parseResumeState?.ownedTokenMaxima[0] - const second = decoded.parseResumeState?.ownedTokenMaxima[1] - if (!first || !second) { - throw new Error('Expected independent decoded rows.') - } - expect(first).not.toBe(second) - first[0]++ - expect(second[0]).toBe(100) - expect(original).toEqual(before) -}) - -it('preserves independently varying finite maxima, null routing and original integrity', () => { - const original = fixture([ - [-1.5, 2.25, 0, 20, 5, 0], - [3.75, 1, 4.5, 21, 6.25, 1], - [Number.MAX_VALUE, -2.5, -3.25, 22, -1.5, null] - ]) - const packed = pack(original) - expect(columns(packed)).toEqual([ - [-1.5, 3.75, Number.MAX_VALUE], - [2.25, 1, -2.5], - [0, 4.5, -3.25], - [20, 21, 22], - [5, 6.25, -1.5], - [0, 1, null] - ]) - const decoded = decode(packed).file - expect(decoded).toEqual(original) - expect(buildClaudeUsageProjectionIntegrity(decoded)).toBe( - original.parseResumeState?.projectionIntegrity - ) - expect(hasClaudeUsageResumeProjection(decoded)).toBe(true) -}) - -it('uses empty scalars for zero owned keys and returns legacy and nonresumable files unchanged', () => { - const empty = fixture([]) - expect(columns(pack(empty))).toEqual([0, 0, 0, 0, 0, null]) - expect(decode(pack(empty)).file).toEqual(empty) - expect(decode(empty)).toEqual({ file: empty, yields: 0 }) - expect(decode(empty).file).toBe(empty) - const small = { ...empty, parseResumeState: null } - expect(decode(small).file).toBe(small) - expect(pack(small)).toEqual(small) -}) - -it('yields every 1024 decoded rows before publishing a complete file', () => { - const rows: ClaudeUsageTokenMaxima[] = Array.from({ length: 2050 }, (_, index) => [ - index, - 10, - 0, - 20, - 5, - 0 - ]) - const original = fixture(rows) - const iterator = decodeClaudeUsagePersistedFile(pack(original)) - expect(iterator.next()).toEqual({ value: undefined, done: false }) - expect(iterator.next()).toEqual({ value: undefined, done: false }) - const completed = iterator.next() - expect(completed.done).toBe(true) - expect(completed.value).toEqual(original) -}) - -it('leaves a finite packed maximum drift visible to the coupled integrity validator', () => { - const original = fixture([[100, 10, 0, 20, 5, 0]]) - const packed = pack(original) - columns(packed)[0] = 99 - const decoded = decode(packed).file - expect(decoded.parseResumeState?.ownedTokenMaxima[0]?.[0]).toBe(99) - expect(decoded.parseResumeState?.projectionIntegrity).toBe( - original.parseResumeState?.projectionIntegrity - ) - expect(hasClaudeUsageResumeProjection(decoded)).toBe(false) -}) - -it('serializes a frozen source array and diagnostic state exactly like individual packing', () => { - const first = fixture([[100, 10, 0, 20, 5, 0]]) - const second = fixture([ - [101, 11, 1, 21, 6, 0], - [102, 12, 2, 22, 7, 0] - ]) - const unsigned = structuredClone(first) - if (!unsigned.parseResumeState) { - throw new Error('Expected an unsigned checkpoint fixture.') - } - delete unsigned.parseResumeState.projectionIntegrity - const state = savedState(first) - state.processedFiles.push(second, unsigned, { ...first, parseResumeState: null }) - const before = structuredClone(state) - const expected = { ...state, processedFiles: state.processedFiles.map(pack) } - for (const file of state.processedFiles) { - if (file.parseResumeState) { - file.parseResumeState.ownedTokenMaxima.forEach(Object.freeze) - Object.freeze(file.parseResumeState.ownedTokenMaxima) - Object.freeze(file.parseResumeState) - } - Object.freeze(file.ownedDedupeKeys) - Object.freeze(file) - } - Object.freeze(state.processedFiles) - Object.freeze(state) - expect(JSON.stringify(encodeClaudeUsagePersistedFiles(state.processedFiles))).toBe( - JSON.stringify(expected.processedFiles) - ) - expect(JSON.stringify(encodeClaudeUsagePersistedState(state))).toBe(JSON.stringify(expected)) - expect(pack(unsigned)).toEqual(unsigned) - expect(state).toEqual(before) -}) - -it.each([ - { name: 'null column', index: 0, value: null }, - { name: 'string column', index: 1, value: '10' }, - { name: 'nonfinite scalar', index: 2, value: Number.POSITIVE_INFINITY }, - { name: 'short column', index: 0, value: [100] }, - { name: 'long column', index: 0, value: [100, 200, 300] }, - { name: 'nonnumeric value', index: 1, value: [10, '20'] }, - { name: 'null token value', index: 2, value: [0, null] }, - { name: 'nonfinite value', index: 3, value: [20, Number.NaN] }, - { name: 'negative route', index: 5, value: [0, -1] }, - { name: 'fractional route', index: 5, value: [0, 0.5] }, - { name: 'unknown route', index: 5, value: [0, 2] }, - { name: 'nonnumeric route', index: 5, value: [0, true] } -])('rejects $name before publishing decoded rows', ({ index, value }) => { - const packed = pack( - fixture([ - [100, 10, 0, 20, 5, 0], - [200, 20, 1, 30, 6, 1] - ]) - ) - columns(packed)[index] = value - expect(() => decode(packed)).toThrow('Saved Claude usage token') -}) - -it.each([ - { tokenCodecVersion: undefined }, - { tokenCodecVersion: 0 }, - { tokenCodecVersion: 2 }, - { tokenCodecVersion: '1' }, - { ownedTokenColumns: undefined }, - { ownedTokenColumns: {} }, - { ownedTokenColumns: [1, 2, 3, 4, 5] }, - { ownedTokenColumns: [1, 2, 3, 4, 5, null, 7] }, - { ownedTokenMaxima: [[100, 10, 0, 20, 5, 0]] }, - { projectionIntegrity: undefined }, - { projectionIntegrity: null }, - { projectionIntegrity: 123 } -])('rejects unsupported, incomplete or duplicated codec fields %j', (patch) => { - const packed = pack(fixture([[100, 10, 0, 20, 5, 0]])) - if (!packed.parseResumeState) { - throw new Error('Expected an encoded checkpoint.') - } - const corrupted: ClaudeUsagePersistedFile = JSON.parse( - JSON.stringify({ ...packed, parseResumeState: { ...packed.parseResumeState, ...patch } }) - ) - expect(() => decode(corrupted)).toThrow('Saved Claude usage token') -}) diff --git a/src/main/claude-usage/scanner-incremental-ownership.test.ts b/src/main/claude-usage/scanner-incremental-ownership.test.ts index 1e8adff0a90..3396201d62b 100644 --- a/src/main/claude-usage/scanner-incremental-ownership.test.ts +++ b/src/main/claude-usage/scanner-incremental-ownership.test.ts @@ -211,87 +211,6 @@ describe('Claude usage append ownership', () => { } ) - it.each([ - 'ownedTokenMaxima', - 'encounterOrder', - 'missingProjections', - 'invalidProjectionIndex', - 'outOfRangeProjectionIndex' - ] as const)( - 'reparses a persisted checkpoint with damaged %s instead of extending an inconsistent projection', - async (missingField) => { - const transcriptPath = join(projectDir, 'damaged-checkpoint.jsonl') - await writeFile( - transcriptPath, - largePrefix() + assistantRow('streamed', { inputTokens: 100 }) - ) - const scan = await scanner() - const first = await scan() - const cached: ClaudeUsagePersistedFile[] = JSON.parse(JSON.stringify(first.processedFiles)) - const checkpoint = processedFile(cached, transcriptPath).parseResumeState - if (!checkpoint) { - throw new Error('Expected a resumable transcript before damaging its checkpoint') - } - if (missingField === 'missingProjections') { - Reflect.deleteProperty(checkpoint, 'projections') - } else if ( - missingField === 'invalidProjectionIndex' || - missingField === 'outOfRangeProjectionIndex' - ) { - const maxima = checkpoint.ownedTokenMaxima[0] - if (!maxima) { - throw new Error('Expected retained token maxima before damaging their projection index') - } - maxima[5] = missingField === 'invalidProjectionIndex' ? 0.5 : checkpoint.projections.length - } else { - checkpoint[missingField] = [] - } - await appendFile(transcriptPath, assistantRow('streamed', { inputTokens: 150 })) - streamReads.length = 0 - - const second = await scan(cached) - expect(parseReadOffsets(transcriptPath)).toEqual([0]) - const cold = await scan() - expect(second.sessions).toEqual(cold.sessions) - expect(second.dailyAggregates).toEqual(cold.dailyAggregates) - expect(second.sessions[0]?.turnCount).toBe(1) - expect(second.sessions[0]?.totalInputTokens).toBe(150) - } - ) - - it('keeps a large unchanged deferred fork cached when its small owner only appends', async () => { - const ownerPath = join(projectDir, 'aaaa-small-owner.jsonl') - const forkPath = join(projectDir, 'zzzz-large-deferred-fork.jsonl') - await writeFile(ownerPath, assistantRow('shared', { inputTokens: 100 })) - await writeFile( - forkPath, - `${JSON.stringify({ type: 'user', content: '界'.repeat(350_000) })}\n${assistantRow( - 'shared', - { - sessionId: 'fork-session', - inputTokens: 100 - } - )}` - ) - const scan = await scanner() - const first = await scan() - const cachedFork = processedFile(first.processedFiles, forkPath) - expect(cachedFork.hasDeferredClaims).toBe(true) - expect(cachedFork.size).toBeGreaterThan(1_000_000) - expect(processedFile(first.processedFiles, ownerPath).parseResumeState).toBeNull() - await appendFile(ownerPath, assistantRow('owner-new', { inputTokens: 50 })) - streamReads.length = 0 - - const second = await scan(first.processedFiles) - expect(processedFile(second.processedFiles, forkPath)).toBe(cachedFork) - expect(streamReads.filter((read) => read.path === forkPath)).toEqual([]) - const cold = await scan() - expect(second.sessions).toEqual(cold.sessions) - expect(second.dailyAggregates).toEqual(cold.dailyAggregates) - expect(second.sessions[0]?.turnCount).toBe(2) - expect(second.sessions[0]?.totalInputTokens).toBe(150) - }) - it('retains a resumed owner when a fork of its earlier history appears', async () => { const ownerPath = join(projectDir, 'aaaa-owner.jsonl') const forkPath = join(projectDir, 'zzzz-late-fork.jsonl') @@ -592,108 +511,4 @@ describe('Claude usage append ownership', () => { totalCacheWrite1hTokens: 4 }) }) - - it('keeps an invalid first timestamp uncounted when a later duplicate has valid metadata', async () => { - const transcriptPath = join(projectDir, 'invalid-first-timestamp.jsonl') - await writeFile( - transcriptPath, - largePrefix() + assistantRow('invalid-first', { timestamp: 'invalid-timestamp' }) - ) - const scan = await scanner() - const first = await scan() - expect(first.sessions).toEqual([]) - expect(processedFile(first.processedFiles, transcriptPath).ownedDedupeKeys).toEqual([ - 'message-invalid-first:request-invalid-first' - ]) - await appendFile( - transcriptPath, - assistantRow('invalid-first', { sessionId: 'valid-later-session', inputTokens: 20 }) - ) - streamReads.length = 0 - const second = await scan(first.processedFiles) - expect(parseReadOffsets(transcriptPath)).toEqual([ - processedFile(first.processedFiles, transcriptPath).size - ]) - const cold = await scan() - expect(second.sessions).toEqual(cold.sessions) - expect(second.dailyAggregates).toEqual(cold.dailyAggregates) - expect(second.sessions).toEqual([]) - expect(second.dailyAggregates).toEqual([]) - }) - - it('uses first session encounter order when an append creates a last-timestamp tie', async () => { - const transcriptPath = join(projectDir, 'session-tie.jsonl') - await writeFile( - transcriptPath, - largePrefix() + - assistantRow('session-a-first', { sessionId: 'session-a' }) + - assistantRow('session-b-first', { - sessionId: 'session-b', - timestamp: '2026-10-09T13:00:00.000Z' - }) - ) - const scan = await scanner() - const first = await scan() - expect(first.sessions.map((session) => session.sessionId)).toEqual(['session-b', 'session-a']) - - await appendFile( - transcriptPath, - assistantRow('session-a-later', { - sessionId: 'session-a', - timestamp: '2026-10-09T13:00:00.000Z' - }) - ) - streamReads.length = 0 - const second = await scan(first.processedFiles) - expect(parseReadOffsets(transcriptPath)).toEqual([ - processedFile(first.processedFiles, transcriptPath).size - ]) - const cold = await scan() - expect(second.sessions).toEqual(cold.sessions) - expect(second.dailyAggregates).toEqual(cold.dailyAggregates) - expect(second.sessions.map((session) => session.sessionId)).toEqual(['session-a', 'session-b']) - }) - - it.each(['keyed', 'unkeyed'])( - 'uses a %s first location encounter when appended maxima create a primary-location tie', - async (firstRowKind) => { - const transcriptPath = join(projectDir, 'location-tie.jsonl') - const secondLocation = assistantRow('second-location', { - cwd: worktrees[1]?.path, - inputTokens: 20 - }) - const history = - firstRowKind === 'unkeyed' - ? assistantRow('unkeyed-location', { inputTokens: 5, identified: false }) + - secondLocation + - assistantRow('first-location', { inputTokens: 5 }) - : assistantRow('first-location', { inputTokens: 10 }) + secondLocation - await writeFile(transcriptPath, largePrefix() + history) - const scan = await scanner() - const first = await scan() - expect(first.sessions[0]?.primaryWorktreeId).toBe('worktree-b') - expect(first.sessions[0]?.locationBreakdown.map((location) => location.worktreeId)).toEqual([ - 'worktree-b', - 'worktree-a' - ]) - await appendFile( - transcriptPath, - assistantRow('first-location', { inputTokens: firstRowKind === 'unkeyed' ? 15 : 20 }) - ) - streamReads.length = 0 - const second = await scan(first.processedFiles) - expect(parseReadOffsets(transcriptPath)).toEqual([ - processedFile(first.processedFiles, transcriptPath).size - ]) - const cold = await scan() - expect(second.sessions).toEqual(cold.sessions) - expect(second.dailyAggregates).toEqual(cold.dailyAggregates) - expect(second.sessions[0]?.primaryWorktreeId).toBe('worktree-a') - expect(second.sessions[0]?.turnCount).toBe(firstRowKind === 'unkeyed' ? 3 : 2) - expect(second.sessions[0]?.locationBreakdown.map((location) => location.worktreeId)).toEqual([ - 'worktree-a', - 'worktree-b' - ]) - } - ) }) diff --git a/src/main/claude-usage/scanner-incremental-reader.test.ts b/src/main/claude-usage/scanner-incremental-reader.test.ts index cf56d92354b..47eab695f6a 100644 --- a/src/main/claude-usage/scanner-incremental-reader.test.ts +++ b/src/main/claude-usage/scanner-incremental-reader.test.ts @@ -242,36 +242,6 @@ describe('Claude usage incremental reader', () => { await expectColdParity(result) }, 5000) - it('rejects a short sample from an unchanged file once and closes its only handle', async () => { - await writeFile(transcriptPath, padding() + assistantRow('old', 10)) - const original = await stat(transcriptPath) - reads.readFailure = 'eof' - - await expect(scan()).rejects.toThrow(/snapshot read.*unchanged file boundary/) - - expect(reads.handles).toEqual([{ path: transcriptPath, closes: 1, closed: true }]) - expect(reads.windows).toEqual([{ path: transcriptPath, start: 0, bytes: 0 }]) - expect(reads.streams).toHaveLength(0) - expect(await stat(transcriptPath)).toMatchObject({ - dev: original.dev, - ino: original.ino, - size: original.size, - mtimeMs: original.mtimeMs, - ctimeMs: original.ctimeMs - }) - }, 5000) - - it('propagates a sampling access failure without retrying and closes its only handle', async () => { - await writeFile(transcriptPath, padding() + assistantRow('old', 10)) - const failure = Object.assign(new Error('Sampling access denied'), { code: 'EACCES' }) - reads.readFailure = failure - - await expect(scan()).rejects.toBe(failure) - - expect(reads.handles).toEqual([{ path: transcriptPath, closes: 1, closed: true }]) - expect(reads.streams).toHaveLength(0) - }, 5000) - it('rejects a zero-byte post-parse verification read from an unchanged file without retrying', async () => { const content = padding() + assistantRow('old', 10) + assistantRow('new', 20) const contentBytes = Buffer.byteLength(content) @@ -458,72 +428,6 @@ describe('Claude usage incremental reader', () => { await expectColdParity(second) }) - it('rebuilds an older cached projection without a resume checkpoint', async () => { - const prefix = padding() + assistantRow('old', 10) - await writeFile(transcriptPath, prefix) - const first = await scan() - const legacyFiles = first.processedFiles.map(({ parseResumeState, ...file }) => { - expect(parseResumeState).toBeTruthy() - return file - }) - const appended = assistantRow('new', 20) - await appendFile(transcriptPath, appended) - reads.streams.length = 0 - - const second = await scan(legacyFiles) - - expectParseRange(0, Buffer.byteLength(prefix + appended)) - expect(second.sessions[0]?.totalInputTokens).toBe(30) - await expectColdParity(second) - }) - - it.each([ - { change: 'an unchanged file', total: 10 }, - { change: 'an atomic same-size replacement', total: 90 } - ])('upgrades legacy identity metadata once for $change', async ({ change, total }) => { - const timestamp = new Date('2026-10-09T12:00:00.000Z') - const prefix = padding() - const original = prefix + assistantRow('old', 10) - await writeFile(transcriptPath, original) - await utimes(transcriptPath, timestamp, timestamp) - const first = await scan() - const legacyFiles = first.processedFiles.map(({ physicalFileId, ctimeMs, ...file }) => { - expect(physicalFileId).not.toBeUndefined() - expect(typeof ctimeMs).toBe('number') - return file - }) - if (change === 'an atomic same-size replacement') { - const replacement = prefix + assistantRow('new', 90) - expect(Buffer.byteLength(replacement)).toBe(Buffer.byteLength(original)) - const replacementPath = `${transcriptPath}.replacement` - await writeFile(replacementPath, replacement) - await utimes(replacementPath, timestamp, timestamp) - await rename(replacementPath, transcriptPath) - } - const currentStats = await stat(transcriptPath) - expect(currentStats.size).toBe(onlyFile(first).size) - expect(currentStats.mtimeMs).toBe(onlyFile(first).mtimeMs) - reads.streams.length = 0 - - const upgraded = await scan(legacyFiles) - - expectParseRange(0, Buffer.byteLength(original)) - expect(onlyFile(upgraded).physicalFileId).not.toBeUndefined() - expect(typeof onlyFile(upgraded).ctimeMs).toBe('number') - expect(upgraded.sessions[0]?.totalInputTokens).toBe(total) - await expectColdParity(upgraded) - reads.streams.length = 0 - reads.windows.length = 0 - reads.handles.length = 0 - - const unchanged = await scan(upgraded.processedFiles) - - expect(onlyFile(unchanged)).toBe(onlyFile(upgraded)) - expect(reads.streams).toHaveLength(0) - expect(reads.windows).toHaveLength(0) - expect(reads.handles).toHaveLength(0) - }) - it.each([ { mutation: 'rotation', layout: 'small' }, { mutation: 'in-place rewrite', layout: 'small' }, diff --git a/src/main/claude-usage/scanner-scan.test.ts b/src/main/claude-usage/scanner-scan.test.ts index 8278b12e150..56f7e2549c2 100644 --- a/src/main/claude-usage/scanner-scan.test.ts +++ b/src/main/claude-usage/scanner-scan.test.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type * as Os from 'node:os' -import type * as FsPromises from 'node:fs/promises' const tempRoots: string[] = [] @@ -116,55 +115,6 @@ describe('scanClaudeUsageFiles', () => { expect(result.dailyAggregates[2]?.projectLabel).toBe('Repo A') }) - it('reuses unchanged transcript projections from the previous scan', async () => { - const root = await makeClaudeProjectsRoot() - const projectDir = join(root, '.claude', 'projects', 'project-a') - const transcriptFile = join(projectDir, 'session-1.jsonl') - - await writeFile( - transcriptFile, - JSON.stringify({ - type: 'assistant', - sessionId: 'session-1', - timestamp: '2026-04-09T10:00:00.000Z', - cwd: '/workspace/repo-a', - message: { - model: 'claude-sonnet-4-6', - usage: { - input_tokens: 100, - output_tokens: 20 - } - } - }) - ) - - vi.resetModules() - vi.doMock('os', async () => ({ - ...(await vi.importActual('os')), - homedir: () => root - })) - const { scanClaudeUsageFiles } = await import('./scanner') - const worktrees = [ - { - repoId: 'repo-1', - worktreeId: 'worktree-1', - path: '/workspace/repo-a', - displayName: 'Repo A' - } - ] - - const first = await scanClaudeUsageFiles(worktrees) - const cachedFile = structuredClone(first.processedFiles[0]!) - cachedFile.sessions[0]!.totalInputTokens = 999 - cachedFile.sessions[0]!.locationBreakdown[0]!.inputTokens = 999 - cachedFile.dailyAggregates[0]!.inputTokens = 999 - - const second = await scanClaudeUsageFiles(worktrees, [cachedFile]) - - expect(second.processedFiles[0]?.sessions[0]?.totalInputTokens).toBe(999) - expect(second.dailyAggregates[0]?.inputTokens).toBe(999) - }) - it('dedupes fork copies when only message.id is present (no requestId)', async () => { const root = await makeClaudeProjectsRoot() const projectDir = join(root, '.claude', 'projects', 'project-a') @@ -240,288 +190,4 @@ describe('scanClaudeUsageFiles', () => { expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['uuid:uuid-1']) expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['uuid:uuid-2']) }) - - it('counts turns copied into forked session files exactly once', async () => { - const root = await makeClaudeProjectsRoot() - const projectDir = join(root, '.claude', 'projects', 'project-a') - const originalFile = join(projectDir, 'aaaa-original.jsonl') - const forkFile = join(projectDir, 'bbbb-fork.jsonl') - - const turn = ( - sessionId: string, - messageId: string, - requestId: string, - inputTokens: number - ): string => - JSON.stringify({ - type: 'assistant', - sessionId, - timestamp: '2026-04-09T10:00:00.000Z', - requestId, - cwd: '/workspace/repo-a', - message: { - id: messageId, - model: 'claude-sonnet-4-6', - usage: { input_tokens: inputTokens, output_tokens: 10 } - } - }) - - await writeFile( - originalFile, - [turn('session-1', 'msg_1', 'req_1', 100), turn('session-1', 'msg_2', 'req_2', 200)].join( - '\n' - ) - ) - await writeFile( - forkFile, - [ - turn('session-2', 'msg_1', 'req_1', 100), - turn('session-2', 'msg_2', 'req_2', 200), - turn('session-2', 'msg_3', 'req_3', 400) - ].join('\n') - ) - - vi.resetModules() - vi.doMock('os', async () => ({ - ...(await vi.importActual('os')), - homedir: () => root - })) - const { scanClaudeUsageFiles } = await import('./scanner') - - const result = await scanClaudeUsageFiles([]) - const totalInput = result.dailyAggregates.reduce( - (sum, aggregate) => sum + aggregate.inputTokens, - 0 - ) - - expect(totalInput).toBe(700) - expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1', 'msg_2:req_2']) - expect(result.processedFiles[0]?.hasDeferredClaims).toBe(false) - expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg_3:req_3']) - expect(result.processedFiles[1]?.hasDeferredClaims).toBe(true) - }) - - it('keeps fork dedupe stable when the original file projection is reused from cache', async () => { - const root = await makeClaudeProjectsRoot() - const projectDir = join(root, '.claude', 'projects', 'project-a') - const originalFile = join(projectDir, 'aaaa-original.jsonl') - const forkFile = join(projectDir, 'zzzz-fork.jsonl') - - const turn = ( - sessionId: string, - messageId: string, - requestId: string, - inputTokens: number - ): string => - JSON.stringify({ - type: 'assistant', - sessionId, - timestamp: '2026-04-09T10:00:00.000Z', - requestId, - cwd: '/workspace/repo-a', - message: { - id: messageId, - model: 'claude-sonnet-4-6', - usage: { input_tokens: inputTokens, output_tokens: 10 } - } - }) - - await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100)) - - vi.resetModules() - vi.doMock('os', async () => ({ - ...(await vi.importActual('os')), - homedir: () => root - })) - const { scanClaudeUsageFiles } = await import('./scanner') - - const first = await scanClaudeUsageFiles([]) - expect(first.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1']) - - await writeFile( - forkFile, - [turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n') - ) - - const second = await scanClaudeUsageFiles([], first.processedFiles) - const totalInput = second.dailyAggregates.reduce( - (sum, aggregate) => sum + aggregate.inputTokens, - 0 - ) - - expect(totalInput).toBe(150) - expect(second.processedFiles.map((file) => file.ownedDedupeKeys)).toEqual([ - ['msg_1:req_1'], - ['msg_9:req_9'] - ]) - - const third = await scanClaudeUsageFiles([], second.processedFiles) - expect(third.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe( - 150 - ) - }) - - it('reclaims fork-copied turns when the owning original file is deleted', async () => { - const root = await makeClaudeProjectsRoot() - const projectDir = join(root, '.claude', 'projects', 'project-a') - const originalFile = join(projectDir, 'aaaa-original.jsonl') - const forkFile = join(projectDir, 'bbbb-fork.jsonl') - - const turn = ( - sessionId: string, - messageId: string, - requestId: string, - inputTokens: number - ): string => - JSON.stringify({ - type: 'assistant', - sessionId, - timestamp: '2026-04-09T10:00:00.000Z', - requestId, - cwd: '/workspace/repo-a', - message: { - id: messageId, - model: 'claude-sonnet-4-6', - usage: { input_tokens: inputTokens, output_tokens: 10 } - } - }) - - await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100)) - await writeFile( - forkFile, - [turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n') - ) - - vi.resetModules() - vi.doMock('os', async () => ({ - ...(await vi.importActual('os')), - homedir: () => root - })) - const { scanClaudeUsageFiles } = await import('./scanner') - - const first = await scanClaudeUsageFiles([]) - expect(first.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe( - 150 - ) - expect(first.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1']) - expect(first.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg_9:req_9']) - - await rm(originalFile) - - const second = await scanClaudeUsageFiles([], first.processedFiles) - expect(second.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe( - 150 - ) - expect(second.processedFiles).toHaveLength(1) - expect(second.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1', 'msg_9:req_9']) - }) - - it('keeps unrelated cached transcripts when a different owner file is deleted', async () => { - const root = await makeClaudeProjectsRoot() - const projectDir = join(root, '.claude', 'projects', 'project-a') - const originalFile = join(projectDir, 'aaaa-original.jsonl') - const forkFile = join(projectDir, 'bbbb-fork.jsonl') - const unrelatedFile = join(projectDir, 'cccc-unrelated.jsonl') - - const turn = ( - sessionId: string, - messageId: string, - requestId: string, - inputTokens: number - ): string => - JSON.stringify({ - type: 'assistant', - sessionId, - timestamp: '2026-04-09T10:00:00.000Z', - requestId, - cwd: '/workspace/repo-a', - message: { - id: messageId, - model: 'claude-sonnet-4-6', - usage: { input_tokens: inputTokens, output_tokens: 10 } - } - }) - - await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100)) - await writeFile( - forkFile, - [turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n') - ) - await writeFile(unrelatedFile, turn('session-3', 'msg_u', 'req_u', 25)) - - vi.resetModules() - vi.doMock('os', async () => ({ - ...(await vi.importActual('os')), - homedir: () => root - })) - const { scanClaudeUsageFiles } = await import('./scanner') - - const first = await scanClaudeUsageFiles([]) - const unrelatedBefore = first.processedFiles.find((file) => file.path === unrelatedFile) - expect(unrelatedBefore?.hasDeferredClaims).toBe(false) - expect(unrelatedBefore?.ownedDedupeKeys).toEqual(['msg_u:req_u']) - - await rm(originalFile) - - const second = await scanClaudeUsageFiles([], first.processedFiles) - const unrelatedAfter = second.processedFiles.find((file) => file.path === unrelatedFile) - expect(unrelatedAfter).toBe(unrelatedBefore) - expect(second.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe( - 175 - ) - }) - - it('canonicalizes repeated cwd paths once per scan file', async () => { - const root = await makeClaudeProjectsRoot() - const projectDir = join(root, '.claude', 'projects', 'project-a') - const transcriptFile = join(projectDir, 'session-1.jsonl') - const repeatedCwd = '/workspace/repo-a/packages/app' - - await writeFile( - transcriptFile, - [1, 2, 3] - .map((index) => - JSON.stringify({ - type: 'assistant', - sessionId: 'session-1', - timestamp: `2026-04-09T10:0${index}:00.000Z`, - cwd: repeatedCwd, - message: { - model: 'claude-sonnet-4-6', - usage: { - input_tokens: 100, - output_tokens: 20 - } - } - }) - ) - .join('\n') - ) - - const realpathCalls: string[] = [] - vi.resetModules() - vi.doMock('os', async () => ({ - ...(await vi.importActual('os')), - homedir: () => root - })) - vi.doMock('fs/promises', async () => ({ - ...(await vi.importActual('fs/promises')), - realpath: vi.fn(async (pathValue: string) => { - realpathCalls.push(pathValue) - return pathValue - }) - })) - const { scanClaudeUsageFiles } = await import('./scanner') - - await scanClaudeUsageFiles([ - { - repoId: 'repo-1', - worktreeId: 'worktree-1', - path: '/workspace/repo-a', - displayName: 'Repo A' - } - ]) - - expect(realpathCalls.filter((pathValue) => pathValue === repeatedCwd)).toHaveLength(1) - }) }) diff --git a/src/main/claude-usage/store-checkpoint-persistence.test.ts b/src/main/claude-usage/store-checkpoint-persistence.test.ts index 9d45f2d211a..160b22315ca 100644 --- a/src/main/claude-usage/store-checkpoint-persistence.test.ts +++ b/src/main/claude-usage/store-checkpoint-persistence.test.ts @@ -148,31 +148,6 @@ it('reconstructs duplicated global totals from validated file projections on loa expect(fixture.scanWorker).not.toHaveBeenCalled() }) -it('retains both signed and older aggregate-only file totals when loading a fresh cache', async () => { - const initial = await fixture.writeResumable() - const legacyPath = join(fixture.directory, 'legacy.jsonl') - await writeFile(legacyPath, fixture.row(1)) - const legacy = await projectClaudeUsageScanFile( - await readClaudeUsageScanFile(legacyPath), - new Map(), - () => true - ) - delete legacy.parseResumeState - const state = fixture.state([initial, legacy]) - await writeFile(fixture.reportPath, JSON.stringify(state)) - - const store = fixture.createStore() - await store.whenLoaded() - expect(store.getSnapshot('all', 'all')).toMatchObject({ - summary: { sessions: 1, turns: 2, inputTokens: 2001 }, - daily: [{ inputTokens: 2001 }], - scanState: { enabled: true, lastScanCompletedAt: state.scanState.lastScanCompletedAt } - }) - expect(await fixture.decodedSources()).toEqual([initial, legacy]) - await store.refresh(false) - expect(fixture.scanWorker).not.toHaveBeenCalled() -}) - it.each(['nonArray', 'nullEntry', 'unsignedRollup'] as const)( 'preserves tracking when malformed %s cache records cannot be validated', async (malformation) => { diff --git a/src/main/claude-usage/store-source-authority.test.ts b/src/main/claude-usage/store-source-authority.test.ts index ec1d0a73969..241ab4f1a01 100644 --- a/src/main/claude-usage/store-source-authority.test.ts +++ b/src/main/claude-usage/store-source-authority.test.ts @@ -1,7 +1,6 @@ import { appendFile, readFile, writeFile } from 'node:fs/promises' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { UsageCacheSnapshotWriter } from '../usage/usage-cache-snapshot-writer' -import * as sourceCacheWriter from '../usage/usage-source-cache-file' import { readClaudeUsageSourceCache, writeClaudeUsageSourceCache } from './persisted-source-cache' import { serializeClaudeUsageReport } from './persisted-usage-report' import { @@ -30,44 +29,6 @@ afterEach(async () => { await fixture.cleanup() }) -it('resumes an older source generation independently of a newer report', async () => { - const original = await fixture.writeResumable() - await appendFile(fixture.transcript, fixture.row(0, true)) - const updated = await fixture.project() - await writeClaudeUsageSourceCache({ ...fixture.sourceRef, schemaVersion: 6 }, [original]) - await writeFile(fixture.reportPath, serializeClaudeUsageReport(fixture.state([updated], 7))) - - const store = fixture.createStore() - await store.whenLoaded() - expect(store.getSnapshot('all', 'all').summary.inputTokens).toBe(2000) - expect(await fixture.decodedSources()).toEqual([original]) - expect(fixture.splitWorker).not.toHaveBeenCalled() - await store.refresh(true) - await store.flush() - expect(await fixture.decodedSources()).toEqual([updated]) - expect(store.getSnapshot('all', 'all').summary).toMatchObject({ turns: 1, inputTokens: 2000 }) - expect(JSON.parse(await readFile(fixture.sourceRef.path, 'utf8')).schemaVersion).toBe(7) -}) - -it('uses a newer source generation independently of an older report', async () => { - const original = await fixture.writeResumable() - await appendFile(fixture.transcript, fixture.row(0, true)) - const updated = await fixture.project() - await writeClaudeUsageSourceCache(fixture.sourceRef, [updated]) - await writeFile(fixture.reportPath, serializeClaudeUsageReport(fixture.state([original], 6))) - - const store = fixture.createStore() - await store.whenLoaded() - expect(store.getSnapshot('all', 'all').summary.inputTokens).toBe(1000) - expect(await fixture.decodedSources()).toEqual([updated]) - expect(fixture.splitWorker).not.toHaveBeenCalled() - await store.refresh(true) - await store.flush() - expect(await fixture.decodedSources()).toEqual([updated]) - expect(store.getSnapshot('all', 'all').summary).toMatchObject({ turns: 1, inputTokens: 2000 }) - expect(JSON.parse(await readFile(fixture.reportPath, 'utf8')).schemaVersion).toBe(7) -}) - it('keeps a newer source generation when an earlier report migration could not be written', async () => { const original = await fixture.writeResumable() const inline = fixture.state([original]) @@ -164,24 +125,3 @@ it('rejects a source cache attributed to a different worktree fingerprint', asyn expect(await fixture.decodedSources()).toEqual([original]) expect(store.getSnapshot('all', 'all').summary.inputTokens).toBe(1000) }) - -it('updates the completed report while retaining an unchanged verified sidecar', async () => { - const original = await fixture.writeResumable() - await writeClaudeUsageSourceCache(fixture.sourceRef, [original]) - const state = fixture.state([original], 7) - state.scanState.lastScanCompletedAt = 1 - await writeFile(fixture.reportPath, serializeClaudeUsageReport(state)) - const sourceText = await readFile(fixture.sourceRef.path, 'utf8') - const write = vi.spyOn(sourceCacheWriter, 'writeUsageSourceCacheData') - const store = fixture.createStore() - await store.whenLoaded() - - await store.refresh(true) - await store.flush() - - expect(write).not.toHaveBeenCalled() - expect(await readFile(fixture.sourceRef.path, 'utf8')).toBe(sourceText) - const report = JSON.parse(await readFile(fixture.reportPath, 'utf8')) - expect(report.scanState.lastScanCompletedAt).toBeGreaterThan(1) - expect(store.getSnapshot('all', 'all').summary.inputTokens).toBe(1000) -}) diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 825f7237831..7f3cf203f68 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -107,618 +107,6 @@ describe('ClaudeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('defaults a null legacy opt-in while invalidating the cache', () => { - writeFileSync( - join(tempUserData, 'orca-claude-usage.json'), - JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } }) - ) - - const store = new ClaudeUsageStore(createBackingStore()) - - expect(store.getScanState().enabled).toBe(false) - }) - - it('reports no data for Orca scope when only non-Orca usage exists', async () => { - const store = createStoreWithState({ - sessions: [ - { - sessionId: 'session-1', - firstTimestamp: '2026-04-09T10:00:00.000Z', - lastTimestamp: '2026-04-09T10:10:00.000Z', - model: 'claude-sonnet-4-6', - lastCwd: '/outside/repo', - lastGitBranch: 'feature/outside', - primaryWorktreeId: null, - primaryRepoId: null, - turnCount: 1, - totalInputTokens: 100, - totalOutputTokens: 20, - totalCacheReadTokens: 10, - totalCacheWriteTokens: 5, - totalCacheWrite1hTokens: 0, - locationBreakdown: [ - { - locationKey: 'cwd:/outside/repo', - projectLabel: 'outside/repo', - repoId: null, - worktreeId: null, - turnCount: 1, - inputTokens: 100, - outputTokens: 20, - cacheReadTokens: 10, - cacheWriteTokens: 5, - cacheWrite1hTokens: 0 - } - ] - } - ], - dailyAggregates: [ - { - day: '2026-04-09', - model: 'claude-sonnet-4-6', - projectKey: 'cwd:/outside/repo', - projectLabel: 'outside/repo', - repoId: null, - worktreeId: null, - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 100, - outputTokens: 20, - cacheReadTokens: 10, - cacheWriteTokens: 5, - cacheWrite1hTokens: 0 - } - ] - }) - - const summary = await store.getSummary('orca', '30d') - - expect(summary.hasAnyClaudeData).toBe(false) - expect(summary.sessions).toBe(0) - expect(summary.turns).toBe(0) - expect(summary.zeroCacheReadTurns).toBe(0) - }) - - it('filters sessions by local calendar day instead of raw UTC date prefixes', async () => { - const store = createStoreWithState({ - sessions: [ - { - sessionId: 'session-1', - firstTimestamp: '2026-04-03T23:40:00.000-04:00', - lastTimestamp: '2026-04-03T23:55:00.000-04:00', - model: 'claude-sonnet-4-6', - lastCwd: '/workspace/repo-a', - lastGitBranch: 'feature/a', - primaryWorktreeId: 'repo-1::/workspace/repo-a', - primaryRepoId: 'repo-1', - turnCount: 1, - totalInputTokens: 100, - totalOutputTokens: 20, - totalCacheReadTokens: 10, - totalCacheWriteTokens: 5, - totalCacheWrite1hTokens: 0, - locationBreakdown: [ - { - locationKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - inputTokens: 100, - outputTokens: 20, - cacheReadTokens: 10, - cacheWriteTokens: 5, - cacheWrite1hTokens: 0 - } - ] - } - ], - dailyAggregates: [ - { - day: '2026-04-03', - model: 'claude-sonnet-4-6', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 100, - outputTokens: 20, - cacheReadTokens: 10, - cacheWriteTokens: 5, - cacheWrite1hTokens: 0 - } - ] - }) - - const recentSessions = await store.getRecentSessions('orca', '7d', 10) - - expect(recentSessions).toHaveLength(1) - expect(recentSessions[0]?.sessionId).toBe('session-1') - }) - - it('reports zero-cache-read turns from daily aggregates', async () => { - const store = createStoreWithState({ - dailyAggregates: [ - { - day: '2026-04-09', - model: 'claude-sonnet-4-6', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 5, - zeroCacheReadTurnCount: 2, - inputTokens: 100, - outputTokens: 20, - cacheReadTokens: 10, - cacheWriteTokens: 5, - cacheWrite1hTokens: 0 - } - ] - }) - - const summary = await store.getSummary('orca', '30d') - - expect(summary.turns).toBe(5) - expect(summary.zeroCacheReadTurns).toBe(2) - }) - - it('prices Claude Opus 4.7 with current Anthropic rates', async () => { - const store = createStoreWithState({ - dailyAggregates: [ - { - day: '2026-04-09', - model: 'claude-opus-4-7-20260416', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - } - ] - }) - - const summary = await store.getSummary('orca', '30d') - const breakdown = await store.getBreakdown('orca', '30d', 'model') - - expect(summary.estimatedCostUsd).toBeCloseTo(36.75) - expect( - breakdown.find((row) => row.key === 'claude-opus-4-7-20260416')?.estimatedCostUsd - ).toBeCloseTo(36.75) - }) - - it('prices the 1-hour cache-write share above the 5-minute rate', async () => { - const store = createStoreWithState({ - dailyAggregates: [ - { - day: '2026-04-09', - model: 'claude-opus-4-7-20260416', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 400_000 - } - ] - }) - - const summary = await store.getSummary('orca', '30d') - const breakdown = await store.getBreakdown('orca', '30d', 'model') - - expect(summary.estimatedCostUsd).toBeCloseTo(38.25) - expect( - breakdown.find((row) => row.key === 'claude-opus-4-7-20260416')?.estimatedCostUsd - ).toBeCloseTo(38.25) - }) - - it('prices Claude Opus 4.8 with current Anthropic rates', async () => { - const store = createStoreWithState({ - dailyAggregates: [ - { - day: '2026-04-09', - model: 'anthropic/claude-opus-4-8-20260528', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - }, - { - day: '2026-04-09', - model: 'claude-opus-4.8-20260528', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - } - ] - }) - - const summary = await store.getSummary('orca', '30d') - const breakdown = await store.getBreakdown('orca', '30d', 'model') - - expect(summary.estimatedCostUsd).toBeCloseTo(73.5) - expect( - breakdown.find((row) => row.key === 'anthropic/claude-opus-4-8-20260528')?.estimatedCostUsd - ).toBeCloseTo(36.75) - expect( - breakdown.find((row) => row.key === 'claude-opus-4.8-20260528')?.estimatedCostUsd - ).toBeCloseTo(36.75) - }) - - it('prices Claude 5 family models with current Anthropic rates', async () => { - const store = createStoreWithState({ - dailyAggregates: [ - { - day: '2026-04-09', - model: 'claude-opus-5', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - }, - { - day: '2026-04-09', - model: 'anthropic/claude-fable-5', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - }, - { - day: '2026-04-09', - model: 'claude-sonnet-5-thinking', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - } - ] - }) - - const breakdown = await store.getBreakdown('orca', '30d', 'model') - - expect(breakdown.find((row) => row.key === 'claude-opus-5')?.estimatedCostUsd).toBeCloseTo( - 36.75 - ) - expect( - breakdown.find((row) => row.key === 'anthropic/claude-fable-5')?.estimatedCostUsd - ).toBeCloseTo(73.5) - expect( - breakdown.find((row) => row.key === 'claude-sonnet-5-thinking')?.estimatedCostUsd - ).toBeCloseTo(14.7) - }) - - it('prices Sonnet 5 long-context usage at flat rates', async () => { - const store = createStoreWithState({ - dailyAggregates: [ - { - day: '2026-04-09', - model: 'claude-sonnet-5', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 300_000, - outputTokens: 300_000, - cacheReadTokens: 300_000, - cacheWriteTokens: 300_000, - cacheWrite1hTokens: 0 - } - ] - }) - - const summary = await store.getSummary('orca', '30d') - - expect(summary.estimatedCostUsd).toBeCloseTo(4.41) - }) - - it('does not collapse Opus 4.5 or Sonnet 4.5 usage into Claude 5 pricing', async () => { - const store = createStoreWithState({ - dailyAggregates: ['claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'].map((model) => ({ - day: '2026-04-09', - model, - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 300_000, - outputTokens: 300_000, - cacheReadTokens: 300_000, - cacheWriteTokens: 300_000, - cacheWrite1hTokens: 0, - longContext: { - inputTokens: 300_000, - outputTokens: 300_000, - cacheReadTokens: 300_000, - cacheWriteTokens: 300_000, - cacheWrite1hTokens: 0 - } - })) - }) - - const breakdown = await store.getBreakdown('orca', '30d', 'model') - - expect( - breakdown.find((row) => row.key === 'claude-sonnet-4-5-20250929')?.estimatedCostUsd - ).toBeCloseTo(10.98) - // Opus 4.5 and 5 share rates, so this assertion cannot distinguish their routing. - expect( - breakdown.find((row) => row.key === 'claude-opus-4-5-20251101')?.estimatedCostUsd - ).toBeCloseTo(11.025) - }) - - it('leaves unknown Opus point releases unpriced instead of borrowing another model', async () => { - const store = createStoreWithState({ - dailyAggregates: [ - { - day: '2026-04-09', - model: 'claude-opus-4-9-20260630', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - }, - { - day: '2026-04-09', - model: 'claude-opus-4.10-20260715', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - } - ] - }) - - const summary = await store.getSummary('orca', '30d') - - expect(summary.estimatedCostUsd).toBeNull() - expect(summary.hasUnpricedModels).toBe(true) - }) - - it('does not collapse older Opus 4.1 or base Opus 4 usage into current Opus pricing', async () => { - const store = createStoreWithState({ - dailyAggregates: [ - { - day: '2026-04-09', - model: 'claude-opus-4-1-20250805', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - }, - { - day: '2026-04-09', - model: 'claude-opus-4-20250514', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 1_000_000, - outputTokens: 1_000_000, - cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000, - cacheWrite1hTokens: 0 - } - ] - }) - - const summary = await store.getSummary('orca', '30d') - - expect(summary.estimatedCostUsd).toBeCloseTo(220.5) - }) - - it('prices Sonnet 4.6 long-context usage at its flat 1M-window rates', async () => { - const store = createStoreWithState({ - dailyAggregates: [ - { - day: '2026-04-09', - model: 'claude-sonnet-4.6-20260217', - projectKey: 'worktree:repo-1::/workspace/repo-a', - projectLabel: 'Repo A', - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo-a', - turnCount: 1, - zeroCacheReadTurnCount: 0, - inputTokens: 300_000, - outputTokens: 300_000, - cacheReadTokens: 300_000, - cacheWriteTokens: 300_000, - cacheWrite1hTokens: 0 - } - ] - }) - - const summary = await store.getSummary('orca', '30d') - - expect(summary.estimatedCostUsd).toBeCloseTo(6.615) - }) - - it('returns automation usage for a single matching worktree session', async () => { - const worktreeId = 'repo-1::/workspace/repo-a' - const completedAt = Date.parse('2026-04-09T15:06:00.000Z') - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: 1, - lastScanCompletedAt: 2, - lastScanError: null - }, - sessions: [createWorktreeUsageSession(worktreeId)] - }) - const refreshMock = vi.fn().mockResolvedValue({ - enabled: true, - isScanning: false, - lastScanStartedAt: 1, - lastScanCompletedAt: 2, - lastScanError: null, - hasAnyClaudeData: true - }) - ;(store as unknown as { refresh: typeof store.refresh }).refresh = refreshMock - const request = { - worktreeId, - terminalSessionId: 'tab-1', - startedAt: completedAt - 7 * 60_000, - completedAt - } - - const usage = await store.getAutomationRunUsage(request) - - expect(usage.status).toBe('known') - expect(usage.providerSessionId).toBe('session-1') - expect(usage.totalTokens).toBe(1800) - expect(usage.estimatedCostUsd).toBeCloseTo(0.010935) - expect(refreshMock).toHaveBeenCalledWith(true) - - ;( - store as unknown as { state: ClaudeUsagePersistedState } - ).state.scanState.lastScanCompletedAt = completedAt + 1000 - refreshMock.mockClear() - await store.getAutomationRunUsage(request) - - expect(refreshMock).toHaveBeenCalledWith(false) - }) - - it('forces one scan per run and stops re-forcing after a failed attempt', async () => { - const completedAt = Date.parse('2026-04-09T15:06:00.000Z') - const scanError = 'EMFILE: too many open files' - const failedScanState = (lastScanStartedAt: number) => ({ - enabled: true, - lastScanStartedAt, - lastScanCompletedAt: completedAt - 60_000, - lastScanError: scanError - }) - const scanStateResult = { - enabled: true, - isScanning: false, - lastScanStartedAt: completedAt - 60_000, - lastScanCompletedAt: completedAt - 60_000, - lastScanError: scanError, - hasAnyClaudeData: false - } - const request = { - worktreeId: 'repo-1::/workspace/repo-a', - terminalSessionId: 'tab-1', - startedAt: completedAt - 120_000, - completedAt - } - - const beforeAttempt = createStoreWithState({ - scanState: failedScanState(completedAt - 60_000) - }) - const beforeRefresh = vi.spyOn(beforeAttempt, 'refresh').mockResolvedValue(scanStateResult) - await beforeAttempt.getAutomationRunUsage(request) - - expect(beforeRefresh).toHaveBeenCalledWith(true) - - const afterAttempt = createStoreWithState({ scanState: failedScanState(completedAt + 1000) }) - const afterRefresh = vi.spyOn(afterAttempt, 'refresh').mockResolvedValue(scanStateResult) - const usage = await afterAttempt.getAutomationRunUsage(request) - - expect(afterRefresh).toHaveBeenCalledWith(false) - expect(usage.unavailableReason).toBe('scan_failed') - }) - - it('hands Claude scans the worker-owned source cache and persists a compact report', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - - await store.refresh(true) - await store.flush() - - expect(scanClaudeUsageFilesViaWorker).toHaveBeenCalledWith([], { - path: join(tempUserData, 'orca-claude-usage-sources.json'), - schemaVersion: 7, - worktreeFingerprint: '[]', - reuse: false - }) - const persisted = readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8') - expect(persisted).not.toContain('\n') - expect(JSON.parse(persisted)).not.toHaveProperty('processedFiles') - }) - it('joins a scan that is already in flight when the run finished before it started', async () => { const worktreeId = 'repo-1::/workspace/repo-a' const store = createStoreWithState({ diff --git a/src/main/claude/agent-view/agent-view-bridge.test.ts b/src/main/claude/agent-view/agent-view-bridge.test.ts index 73bce4df6c6..328fa0236b3 100644 --- a/src/main/claude/agent-view/agent-view-bridge.test.ts +++ b/src/main/claude/agent-view/agent-view-bridge.test.ts @@ -10,74 +10,6 @@ const identity = { afterEach(() => vi.useRealTimers()) describe('Claude agent view delivery', () => { - it('invalidates an in-flight trigger when its mod or session is replaced', async () => { - const bridge = new ClaudeAgentViewBridge() - bridge.receive({ - ...identity, - action: 'register', - view: { agentId: 'previous', revision: 1 }, - keybindings: JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+g': 'command:orca-agent-view' } }] - }) - }) - const trigger = vi.fn(async (_input: string, isCurrent: () => boolean) => { - expect(isCurrent()).toBe(true) - bridge.receive({ - ...identity, - instanceId: 'replacement', - action: 'register', - view: { agentId: 'previous', revision: 1 }, - keybindings: JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+g': 'command:orca-agent-view' } }] - }) - }) - expect(isCurrent()).toBe(false) - return false - }) - expect(await bridge.view({ ...identity, trigger })).toEqual({ status: 'unavailable' }) - }) - it.each([undefined, { revision: 1 }, { agentId: 'child', revision: 1 }])( - 'sends no input for an unknown or already selected view: %j', - async (view) => { - const bridge = new ClaudeAgentViewBridge() - bridge.receive({ - ...identity, - action: 'register', - view, - keybindings: JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+g': 'command:orca-agent-view' } }] - }) - }) - const trigger = vi.fn(async () => true) - expect(await bridge.view({ ...identity, agentId: view?.agentId, trigger })).toEqual({ - status: view ? 'viewed' : 'unavailable' - }) - expect(trigger).not.toHaveBeenCalled() - expect(bridge.receive({ ...identity, action: 'take' })).toEqual({}) - } - ) - - it('rechecks keyboard navigation before delivering a pending shortcut', async () => { - const bridge = new ClaudeAgentViewBridge() - bridge.receive({ - ...identity, - action: 'register', - view: { revision: 1 }, - keybindings: JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+g': 'command:orca-agent-view' } }] - }) - }) - const trigger = vi.fn(async (_input: string, isCurrent: () => boolean) => { - bridge.receive({ ...identity, action: 'observe', view: { agentId: 'child', revision: 2 } }) - expect(isCurrent()).toBe(false) - return false - }) - expect(await bridge.view({ ...identity, agentId: 'child', trigger })).toEqual({ - status: 'unavailable' - }) - expect(bridge.receive({ ...identity, action: 'take' })).toEqual({}) - }) - it('requires a matching live mod and its acknowledgement, and consumes each request once', async () => { const bridge = new ClaudeAgentViewBridge() const trigger = vi.fn(async () => true) @@ -133,46 +65,6 @@ describe('Claude agent view delivery', () => { expect(await parent).toEqual({ status: 'viewed' }) }) - it('publishes observed keyboard switches, rejects reordered reports and withdraws stale sessions', async () => { - vi.useFakeTimers() - const bridge = new ClaudeAgentViewBridge() - const snapshots: unknown[] = [] - const unsubscribe = bridge.subscribe((snapshot) => snapshots.push(snapshot)) - const registration = { - ...identity, - action: 'register', - view: { agentId: 'previous', revision: 1 }, - keybindings: JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+g': 'command:orca-agent-view' } }] - }) - } - bridge.receive({ ...registration, view: { revision: 1 } }) - expect(snapshots.at(-1)).toEqual([{ paneKey: identity.paneKey, sessionId: identity.sessionId }]) - bridge.receive({ - ...identity, - action: 'observe', - view: { agentId: 'keyboard-child', revision: 3 } - }) - expect(snapshots.at(-1)).toEqual([ - { paneKey: identity.paneKey, sessionId: identity.sessionId, agentId: 'keyboard-child' } - ]) - const count = snapshots.length - bridge.receive({ ...registration, view: { revision: 2 } }) - bridge.receive({ - ...identity, - instanceId: 'obsolete', - action: 'observe', - view: { revision: 4 } - }) - expect(snapshots).toHaveLength(count) - bridge.receive({ ...identity, action: 'observe', view: { revision: 4 } }) - expect(snapshots.at(-1)).toEqual([{ paneKey: identity.paneKey, sessionId: identity.sessionId }]) - await vi.advanceTimersByTimeAsync(16000) - expect(snapshots.at(-1)).toEqual([]) - unsubscribe() - expect(vi.getTimerCount()).toBe(0) - }) - it('expires a swallowed trigger so a later shortcut cannot switch views', async () => { vi.useFakeTimers() const bridge = new ClaudeAgentViewBridge() @@ -191,45 +83,4 @@ describe('Claude agent view delivery', () => { expect(await result).toEqual({ status: 'timeout' }) expect(bridge.receive({ ...identity, action: 'take' })).toEqual({}) }) - - it('rejects late acknowledgements from a replaced mod and sends no keys for a stale registration', async () => { - vi.useFakeTimers() - const bridge = new ClaudeAgentViewBridge() - bridge.receive({ - ...identity, - action: 'register', - view: { agentId: 'previous', revision: 1 }, - keybindings: JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+g': 'command:orca-agent-view' } }] - }) - }) - const first = bridge.view({ ...identity, trigger: async () => true }) - const taken = bridge.receive({ ...identity, action: 'take' }) - const replacement = { ...identity, instanceId: 'replacement' } - bridge.receive({ - ...replacement, - action: 'register', - view: { agentId: 'previous', revision: 1 }, - keybindings: JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+h': 'command:orca-agent-view' } }] - }) - }) - expect(await first).toEqual({ status: 'unavailable' }) - const second = bridge.view({ ...identity, trigger: async () => true }) - bridge.receive({ ...identity, ...taken, action: 'ack' }) - bridge.clear() - expect(await second).toEqual({ status: 'unavailable' }) - bridge.receive({ - ...replacement, - action: 'register', - view: { agentId: 'previous', revision: 1 }, - keybindings: JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+h': 'command:orca-agent-view' } }] - }) - }) - await vi.advanceTimersByTimeAsync(15001) - const trigger = vi.fn(async () => true) - expect(await bridge.view({ ...identity, trigger })).toEqual({ status: 'unsupported' }) - expect(trigger).not.toHaveBeenCalled() - }) }) diff --git a/src/main/claude/agent-view/agent-view-install.test.ts b/src/main/claude/agent-view/agent-view-install.test.ts index b93b50d5d13..3b01c7e0722 100644 --- a/src/main/claude/agent-view/agent-view-install.test.ts +++ b/src/main/claude/agent-view/agent-view-install.test.ts @@ -4,15 +4,11 @@ import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' import { parse } from 'jsonc-parser' import { claudeAgentViewChord } from './agent-view-keybindings' -import { createAgentHookMemorySftp } from '../../agent-hooks/agent-hook-memory-sftp.test-fixture' import { installClaudeAgentView, - removeClaudeAgentViewRemote, refreshClaudeAgentView, removeClaudeAgentView } from './agent-view-install' -import { ensureClaudeAgentViewPlugin } from './agent-view-plugin' -import { withClaudeAgentViewShortcut } from './agent-view-binding-plan' const roots: string[] = [] afterEach(() => { @@ -21,81 +17,6 @@ afterEach(() => { } }) -it('retires only owned remote skills without installing new mods or shortcuts', async () => { - const config = '/home/remote/.claude/settings.json' - const root = '/home/remote/.claude/skills/orca-agent-view' - const memory = createAgentHookMemorySftp({ [config]: '{"enabledPlugins":{"user@market":true}}' }) - memory.fs.dirs.add(root) - memory.fs.files.set(`${root}/SKILL.md`, 'user-owned') - const before = [...memory.fs.files] - await removeClaudeAgentViewRemote(memory.sftp, config) - expect([...memory.fs.files]).toEqual(before) - memory.fs.files.set( - `${root}/.claude-plugin/orca-managed.json`, - JSON.stringify({ - owner: 'stablyai/orca/claude-agent-view', - binding: { context: 'Chat', chord: 'ctrl+alt+g' } - }) - ) - memory.fs.dirs.add(`${root}/.claude-plugin`) - memory.fs.files.set( - '/home/remote/.claude/keybindings.json', - JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+g': 'command:orca-agent-view' } }] - }) - ) - await removeClaudeAgentViewRemote(memory.sftp, config) - expect(memory.fs.files.get(config)).toBe('{"enabledPlugins":{"user@market":true}}') - expect( - claudeAgentViewChord(memory.fs.files.get('/home/remote/.claude/keybindings.json') ?? '') - ).toBeUndefined() -}) - -it('installs alongside other skills and plugins without changing their settings or shortcuts', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-agent-view-')) - roots.push(root) - mkdirSync(join(root, 'skills', 'user-skill'), { recursive: true }) - writeFileSync(join(root, 'skills', 'user-skill', 'SKILL.md'), 'User skill') - const settings = JSON.stringify({ - enabledPlugins: { 'user@market': true }, - env: { CLAUDE_CODE_PLUGIN_DIRS: '/my/inline/plugin' } - }) - writeFileSync(join(root, 'settings.json'), settings) - const userBlock = { - context: 'Chat', - bindings: { 'ctrl+alt+g': 'command:user-command', 'ctrl+a': null } - } - writeFileSync( - join(root, 'keybindings.json'), - JSON.stringify({ custom: true, bindings: [userBlock] }) - ) - installClaudeAgentView(join(root, 'settings.json')) - const installed = readFileSync(join(root, 'keybindings.json'), 'utf8') - expect(JSON.parse(installed)).toEqual({ - custom: true, - bindings: [ - userBlock, - { context: 'Chat', bindings: { 'ctrl+alt+h': 'command:orca-agent-view' } } - ] - }) - expect(readFileSync(join(root, 'settings.json'), 'utf8')).toBe(settings) - expect(readFileSync(join(root, 'skills', 'user-skill', 'SKILL.md'), 'utf8')).toBe('User skill') - expect(existsSync(join(root, 'skills', 'orca-agent-view'))).toBe(false) - const plugin = ensureClaudeAgentViewPlugin(join(root, 'orca-user-data')) - expect(readFileSync(join(plugin, 'hooks', 'register.js'), 'utf8')).toContain('$.ui.view(') - expect(ensureClaudeAgentViewPlugin(join(root, 'orca-user-data'))).toBe(plugin) - installClaudeAgentView(join(root, 'settings.json')) - expect(readFileSync(join(root, 'keybindings.json'), 'utf8')).toBe(installed) -}) - -it('leaves unreadable or malformed user bindings untouched', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-agent-view-')) - roots.push(root) - writeFileSync(join(root, 'keybindings.json'), '{broken') - installClaudeAgentView(join(root, 'settings.json')) - expect(readFileSync(join(root, 'keybindings.json'), 'utf8')).toBe('{broken') -}) - it('preserves JSONC comments and trailing commas through install and removal', () => { const root = mkdtempSync(join(tmpdir(), 'orca-agent-view-')) roots.push(root) @@ -122,36 +43,6 @@ it('preserves JSONC comments and trailing commas through install and removal', ( expect(claudeAgentViewChord(removed)).toBeUndefined() }) -it('respects equivalent modifier orders and chord prefixes, and declines exhausted shortcuts', () => { - expect( - withClaudeAgentViewShortcut({ - bindings: [ - { - context: 'Global', - bindings: { - 'Alt+Control+G': 'user:action', - 'opt+ctrl+h x': 'user:chord' - } - } - ] - })?.bindings - ).toContainEqual({ context: 'Chat', bindings: { 'ctrl+alt+v': 'command:orca-agent-view' } }) - expect( - withClaudeAgentViewShortcut({ - bindings: [ - { - context: 'Chat', - bindings: { - 'alt+ctrl+g': null, - 'ctrl+alt+h': null, - 'ctrl+alt+v': null - } - } - ] - }) - ).toBeNull() -}) - it('never overwrites an unowned skill and removes only its own installation', () => { const root = mkdtempSync(join(tmpdir(), 'orca-agent-view-')) roots.push(root) @@ -191,29 +82,6 @@ it('removes only its recorded binding across reinstalls, preserving other contex expect(JSON.parse(readFileSync(path, 'utf8')).bindings).toEqual([...userBindings]) }) -it.each([false, true])('preserves reused user bindings with a legacy marker: %s', (legacy) => { - const root = mkdtempSync(join(tmpdir(), 'orca-agent-view-')) - roots.push(root) - const settings = join(root, 'settings.json') - const path = join(root, 'keybindings.json') - const original = JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'ctrl+alt+g': 'command:orca-agent-view' } }] - }) - writeFileSync(path, original) - if (legacy) { - const markerDir = join(root, 'skills', 'orca-agent-view', '.claude-plugin') - mkdirSync(markerDir, { recursive: true }) - writeFileSync( - join(markerDir, 'orca-managed.json'), - '{"owner":"stablyai/orca/claude-agent-view"}' - ) - } - installClaudeAgentView(settings) - installClaudeAgentView(settings) - removeClaudeAgentView(settings) - expect(readFileSync(path, 'utf8')).toBe(original) -}) - it('remembers removed and edited bindings across launches', () => { const root = mkdtempSync(join(tmpdir(), 'orca-agent-view-')) roots.push(root) diff --git a/src/main/claude/agent-view/agent-view-service.test.ts b/src/main/claude/agent-view/agent-view-service.test.ts index de6a2bc71f6..b0f56f08e6a 100644 --- a/src/main/claude/agent-view/agent-view-service.test.ts +++ b/src/main/claude/agent-view/agent-view-service.test.ts @@ -73,111 +73,6 @@ it.each([{}, { claudeSubagentNavigationEnabled: true }])( } ) -it.each(['edited', 'reused', 'malformed'] as const)( - 'preserves user bindings through disabling and restart: %s', - async (kind) => { - const root = await mkdtemp(join(tmpdir(), 'orca-agent-view-disable-')) - roots.push(root) - const settings = join(root, 'settings.json') - const bindings = join(root, 'keybindings.json') - await reconcileClaudeAgentViewConfig(settings, true) - const original = - kind === 'malformed' - ? '{broken' - : JSON.stringify({ - bindings: [ - { - context: 'Chat', - bindings: { - [kind === 'edited' ? 'ctrl+x' : 'ctrl+alt+g']: - kind === 'edited' ? 'command:orca-agent-view' : 'command:user' - } - } - ] - }) - await writeFile(bindings, original) - await reconcileClaudeAgentViewConfig(settings, false) - expect(await readFile(bindings, 'utf8')).toBe(original) - if (kind === 'malformed') { - await writeFile( - bindings, - '{ // repaired\n "bindings": [{"context":"Chat", "bindings":{"ctrl+alt+g":"command:orca-agent-view"}}]}' - ) - await reconcileClaudeAgentViewConfig(settings, false) - expect(claudeAgentViewChord(await readFile(bindings, 'utf8'))).toBeUndefined() - } - await reconcileClaudeAgentViewConfig(settings, true) - const restored = await readFile(bindings, 'utf8') - if (kind === 'malformed') { - expect(restored).toContain('// repaired') - expect(claudeAgentViewChord(restored)).toBe('ctrl+alt+g') - } else { - expect(restored).toBe(original) - } - } -) - -it('keeps reconciliation ordered and prevents a stale enable from republishing its plugin root', async () => { - const config = join(tmpdir(), 'orca-agent-view-ordered-config') - vi.stubEnv('CLAUDE_CONFIG_DIR', config) - const userData = join(tmpdir(), 'orca-agent-view-ordered-data') - let release = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) - const reconcileConfig = vi.fn(async () => { - await gate - }) - const service = new ClaudeAgentViewService(userData, { - reconcileConfig, - ensurePlugin: async () => join(userData, 'plugin'), - listProfiles: async () => [], - loginEnv: async () => ({ CLAUDE_CONFIG_DIR: config }) - }) - const first = service.reconcile({ claudeSubagentNavigationEnabled: true }) - await vi.waitFor(() => expect(reconcileConfig).toHaveBeenCalledOnce()) - const second = service.reconcile({ claudeSubagentNavigationEnabled: false }) - expect(service.read(config)).toBeUndefined() - release() - await Promise.all([first, second]) - expect(reconcileConfig.mock.calls).toEqual([ - [join(config, 'settings.json'), true], - [join(config, 'settings.json'), false] - ]) - expect(service.read(config)).toBeUndefined() -}) - -it.each([ - { claudeSubagentNavigationEnabled: false }, - { agentStatusHooksEnabled: false }, - { disabledTuiAgents: ['claude' as const] } -])('cleans disabled navigation without probing a login shell: %j', async (settings) => { - const config = join(tmpdir(), 'orca-agent-view-disabled-config') - const custom = join(tmpdir(), 'orca-agent-view-custom-config') - const profile = join(tmpdir(), 'orca-agent-view-account-config') - vi.stubEnv('CLAUDE_CONFIG_DIR', config) - const loginEnv = vi.fn((options) => - options?.cachedOnly ? Promise.resolve({}) : new Promise(() => {}) - ) - const reconcileConfig = vi.fn(async () => {}) - const ensurePlugin = vi.fn(async () => 'unused') - const service = new ClaudeAgentViewService(join(tmpdir(), 'orca-agent-view-disabled-data'), { - reconcileConfig, - ensurePlugin, - loginEnv, - listProfiles: async () => [profile] - }) - await service.reconcile({ - ...settings, - agentDefaultEnv: { claude: { CLAUDE_CONFIG_DIR: custom } } - }) - expect(loginEnv.mock.calls).toEqual([[{ cachedOnly: true }]]) - expect(ensurePlugin).not.toHaveBeenCalled() - expect(reconcileConfig.mock.calls).toEqual( - [config, custom, profile].map((dir) => [join(dir, 'settings.json'), false]) - ) -}) - it('cleans an owned shortcut in a cached login-shell config on a fresh disabled service', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-agent-view-cached-home-')) roots.push(root) @@ -204,80 +99,3 @@ it('cleans an owned shortcut in a cached login-shell config on a fresh disabled expect(loginEnv.mock.calls).toEqual([[{ cachedOnly: true }]]) expect(spawner).toHaveBeenCalledOnce() }) - -it.each([ - { launchAgent: 'codex' as const, launchCommand: 'claude --resume' }, - { launchAgent: 'claude' as const, isWsl: true }, - { launchAgent: 'claude' as const, settings: { claudeSubagentNavigationEnabled: false } }, - { - launchAgent: 'claude' as const, - settings: { claudeSubagentNavigationEnabled: true, agentStatusHooksEnabled: false } - }, - { - launchAgent: 'claude' as const, - settings: { claudeSubagentNavigationEnabled: true, disabledTuiAgents: ['claude' as const] } - } -])('bypasses pending setup for an ineligible launch: %j', async (launch) => { - vi.useFakeTimers() - const config = join(tmpdir(), 'orca-agent-view-ineligible-config') - let release = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) - const service = new ClaudeAgentViewService(join(tmpdir(), 'orca-agent-view-ineligible-data'), { - reconcileConfig: () => gate, - ensurePlugin: async () => join(tmpdir(), 'orca-agent-view-ineligible-plugin'), - loginEnv: async () => ({ CLAUDE_CONFIG_DIR: config }), - listProfiles: async () => [] - }) - const settings = { claudeSubagentNavigationEnabled: true } - let reconciled = false - const reconcile = service.reconcile(settings).then(() => { - reconciled = true - }) - try { - await service.readyForLaunch({ settings, ...launch }) - expect(reconciled).toBe(false) - expect(vi.getTimerCount()).toBe(0) - } finally { - release() - await reconcile - } -}) - -it.each([ - { launchAgent: 'claude' as const }, - { launchCommand: 'claude --resume' }, - { launchAgent: 'claude' as const, settings: { claudeSubagentNavigationEnabled: true } } -])('bounds an enabled Claude launch waiting on stalled setup: %j', async (launch) => { - vi.useFakeTimers() - const config = join(tmpdir(), 'orca-agent-view-stalled-config') - let release = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) - const service = new ClaudeAgentViewService(join(tmpdir(), 'orca-agent-view-stalled-data'), { - reconcileConfig: () => gate, - ensurePlugin: async () => join(tmpdir(), 'orca-agent-view-stalled-plugin'), - loginEnv: async () => ({ CLAUDE_CONFIG_DIR: config }), - listProfiles: async () => [] - }) - const settings = {} - const reconcile = service.reconcile(settings) - let ready = false - const wait = service.readyForLaunch({ settings, ...launch }).then(() => { - ready = true - }) - try { - await vi.advanceTimersByTimeAsync(199) - expect(ready).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await wait - expect(service.read()).toBeUndefined() - expect(vi.getTimerCount()).toBe(0) - } finally { - release() - await reconcile - } - expect(service.read()?.pluginDir).toBeTruthy() -}) diff --git a/src/main/claude/claude-adapter-capture-replay.test.ts b/src/main/claude/claude-adapter-capture-replay.test.ts deleted file mode 100644 index 7bfb8dc1e1b..00000000000 --- a/src/main/claude/claude-adapter-capture-replay.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -// Replay real adapter captures in recorded frame/dispatch order; results define send membership. - -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi, type Mock } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalMessageItem -} from '../../shared/agent-session/journal/agent-session-journal-types' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { readAgentJournalTurn } from '../../shared/agent-session/lifecycle/agent-session-turn-record' -import { agentJournalSubmissionKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' -import { ClaudeStructuredSessionAdapter } from './structured/claude-structured-session-adapter' -import type { - ClaudeLateDispatchOutcome, - ClaudeStructuredSessionAdapterDeps -} from './structured/claude-structured-session-state' -import { - fakeClaude, - identityFor, - PROVIDER_SESSION_ID -} from './structured/claude-structured-session-test-support' -import { claudeStartupSettled } from './structured/claude-structured-startup-settled-test-support' - -type CapturedEvent = - | { at: number; kind: 'meta'; providerSessionId: string } - | { at: number; kind: 'frame'; frame: Record } - | { at: number; kind: 'dispatch'; clientMessageId: string; sentUuid: string; text: string } - | { at: number; kind: 'end' } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function decodeCapturedEvent(value: unknown): CapturedEvent { - if (!isRecord(value) || typeof value.at !== 'number' || typeof value.kind !== 'string') { - throw new Error('capture line is not a recorded event') - } - if (value.kind === 'meta' && typeof value.providerSessionId === 'string') { - return { at: value.at, kind: 'meta', providerSessionId: value.providerSessionId } - } - if (value.kind === 'frame' && isRecord(value.frame)) { - return { at: value.at, kind: 'frame', frame: value.frame } - } - if ( - value.kind === 'dispatch' && - typeof value.clientMessageId === 'string' && - typeof value.sentUuid === 'string' && - typeof value.text === 'string' - ) { - return { - at: value.at, - kind: 'dispatch', - clientMessageId: value.clientMessageId, - sentUuid: value.sentUuid, - text: value.text - } - } - if (value.kind === 'end') { - return { at: value.at, kind: 'end' } - } - throw new Error(`capture line has unknown kind: ${String(value.kind)}`) -} - -function loadCapture(name: string): CapturedEvent[] { - const path = join(__dirname, '__fixtures__', `claude-adapter-capture-${name}.jsonl`) - return readFileSync(path, 'utf8') - .trim() - .split('\n') - .map((line) => decodeCapturedEvent(JSON.parse(line))) -} - -type Replay = { - settled: Mock - /** Final revision per turn id, in first-open order. */ - finalTurns: Map>> - everInterrupted: boolean - /** Each result frame's `user_message_uuids`, mapped to live dispatch uuids. */ - resultMemberships: string[][] - /** clientMessageId -> provider uuid its delivery settled under. */ - settledUuids: Map - /** Client-message ID -> UUID minted at dispatch. */ - liveUuidByClient: Map -} - -async function replayCapture(name: string): Promise { - const capture = loadCapture(name) - let nowMs = 1_700_000_200_000 - const finalTurns = new Map>>() - let everInterrupted = false - const sink: StructuredAgentSessionEventSink = { - appendItem: (_identity, body: AgentJournalItemBody) => { - const turn = readAgentJournalTurn(body) - if (turn) { - finalTurns.set(turn.turnId, turn) - everInterrupted ||= turn.state === 'interrupted' - } - }, - appendTombstone: () => {}, - publish: () => {} - } - const settled = vi.fn<(input: { sessionId: string } & ClaudeLateDispatchOutcome) => void>() - // The capture supplies every frame, startup proof included. - const claude = fakeClaude({ initProof: 'none', replayUuid: null }) - const deps: ClaudeStructuredSessionAdapterDeps = { - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: '/work/repo', - claudeConfigDir: '/accounts/claude', - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - resumesTranscript: false, - cliFlag: async () => 'supported' as const, - continuesChain: false - }), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1, - now: () => nowMs, - persistHandle: async () => {}, - onDispatchSettledLate: settled - } - const adapter = new ClaudeStructuredSessionAdapter(deps) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) - - // Captured uuids -> the uuids the live dispatches mint during this replay. - const uuidMap = new Map() - const capturedSessionId = capture.flatMap((event) => - event.kind === 'meta' ? [event.providerSessionId] : [] - )[0] - const mapFrame = (frame: Record): Record => { - let text = JSON.stringify(frame) - for (const [captured, live] of uuidMap) { - text = text.replaceAll(captured, live) - } - if (capturedSessionId) { - text = text.replaceAll(capturedSessionId, PROVIDER_SESSION_ID) - } - const mapped: unknown = JSON.parse(text) - if (!isRecord(mapped)) { - throw new Error('mapped frame is not a record') - } - return mapped - } - const resultMemberships: string[][] = [] - let startedAwaited = false - for (const event of capture) { - nowMs = 1_700_000_200_000 + event.at - if (event.kind === 'dispatch') { - if (!startedAwaited) { - await claudeStartupSettled(adapter, 'session-1') - startedAwaited = true - } - const body: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: event.text }] - } - const before = new Set(uuidMap.values()) - const outcome = await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: event.clientMessageId, - body, - requestedAt: nowMs, - fence: 7 - }) - expect(outcome).toMatchObject({ state: 'admitted' }) - const sent = claude.connections[0]!.sent.at(-1)?.uuid - if (typeof sent !== 'string' || before.has(sent)) { - throw new Error('replay could not read the dispatched uuid') - } - uuidMap.set(event.sentUuid, sent) - } else if (event.kind === 'frame') { - const frame = mapFrame(event.frame) - if (frame.type === 'result') { - const members = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids : [] - resultMemberships.push(members.filter((m): m is string => typeof m === 'string')) - } - claude.connections[0]!.handlers.onMessage?.(frame) - } - } - const settledUuids = new Map() - for (const [outcome] of settled.mock.calls) { - if ('providerIdentity' in outcome && outcome.providerIdentity.provider === 'claude') { - settledUuids.set(outcome.clientMessageId, outcome.providerIdentity.uuid) - } - } - const liveUuidByClient = new Map() - for (const event of capture) { - if (event.kind === 'dispatch') { - liveUuidByClient.set(event.clientMessageId, uuidMap.get(event.sentUuid)!) - } - } - return { settled, finalTurns, everInterrupted, resultMemberships, settledUuids, liveUuidByClient } -} - -describe.each(['fold', 'early-steer', 'miss'])('adapter capture replay: %s', (name) => { - it('keeps turn membership equal to each result’s user_message_uuids', async () => { - const replay = await replayCapture(name) - - expect([...replay.finalTurns.keys()]).toEqual( - replay.resultMemberships.map((members) => members[0]) - ) - expect(replay.everInterrupted).toBe(false) - for (const turn of replay.finalTurns.values()) { - expect(turn.state).toBe('completed') - } - - const allMembers = new Set(replay.resultMemberships.flat()) - for (const [clientMessageId, liveUuid] of replay.liveUuidByClient) { - expect(replay.settledUuids.get(clientMessageId)).toBe(liveUuid) - expect(allMembers.has(liveUuid)).toBe(true) - } - for (const [index, members] of replay.resultMemberships.entries()) { - for (const member of members) { - const owner = [...replay.finalTurns.keys()].indexOf(member) - if (owner !== -1) { - expect(owner).toBe(index) - } - } - } - }) -}) - -describe('adapter capture replay: slash-goal', () => { - it('opens the turn for the send even though Claude answers the command before echoing it', async () => { - const replay = await replayCapture('slash-goal') - - expect([...replay.finalTurns.values()]).toEqual([ - expect.objectContaining({ - userItemId: agentJournalSubmissionKey('client-A'), - requestedAt: 1_700_000_200_000 + 1504 - }) - ]) - expect(replay.settledUuids.get('client-A')).toBe(replay.liveUuidByClient.get('client-A')) - }) -}) diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index 595f86eea04..d48e13e014e 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -1,5 +1,4 @@ import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' -import { createClaudeStructuredLaunchResolver } from '../runtime/agent-session/launch/structured-agent-launch-composition.test-support' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -13,10 +12,6 @@ import { } from '@anthropic-ai/claude-agent-sdk' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess } from '@orca/process-host' -import type { AgentSessionRecord } from '../../shared/agent-session/lifecycle/agent-session-record' -import type { AgentChatPermissionChoice } from '../../shared/agent-launch/permissions/agent-chat-permission-mode' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' import { ClaudePromptRegistry } from './claude-prompt-registry' import { claudeLaunchPermissions } from './structured/claude-structured-inbound-control' import { claudeStructuredPermissionOptions } from './structured/launch/claude-structured-launch-resolution' @@ -25,23 +20,6 @@ import { claudeStructuredPermissionOptions } from './structured/launch/claude-st 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' - -/** Frozen pre-SDK argv used to detect flag drift. */ -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', @@ -129,38 +107,6 @@ function recordingSpawner(spawns: SpawnSeen[]) { } } -function resolvedLaunch(permissionMode: AgentChatPermissionChoice, 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({ - resolveLaunchArgs: () => launchArgs, - store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath: async () => '/repos/workspace-1', - resolveCommand: () => ({ command: FAKE_CLI, prefixArgs: [] }), - resolveAuthPolicy: () => ({ account: 'managed' }) - })({ - identity: { - sessionId: record.sessionId, - workspaceId: 'workspace-1', - hostId: LOCAL_EXECUTION_HOST_ID, - agent: 'claude', - providerHandle: null - }, - permissionMode - }) -} - function singleUserTurn(): AsyncIterable { return (async function* () { yield { @@ -182,263 +128,7 @@ async function drainQuery(options: Options): Promise[]> return messages } -/** Normalize equivalent --flag=value and --flag value spellings. */ -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)] - }) -} - -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 consumes keep_alive internally, so consumers cannot use it as a liveness signal. - 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 - expect(env.CLAUDE_CONFIG_DIR).toBe('/pinned/claude-config') - expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-token-1') - expect(env.ANTHROPIC_API_KEY).toBeUndefined() - 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) - }) - - 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('leaves own-settings permission resolution to the CLI in the real SDK child argv', async () => { - const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) - const spawns: SpawnSeen[] = [] - const launch = await resolvedLaunch('own') - await drainQuery({ - ...launch.options, - pathToClaudeCodeExecutable: FAKE_CLI, - cwd: scenario.cwd, - env: scenarioEnv(scenario), - canUseTool: async () => ({ behavior: 'deny', message: 'unused' }), - spawnClaudeCodeProcess: recordingSpawner(spawns) - }) - - expect(spawns).toHaveLength(1) - const argv = normalizeArgv(spawns[0]!.args) - expect(argv).not.toContain('--permission-mode') - expect(argv).not.toContain('--dangerously-skip-permissions') - expect(launch.options.permissionMode).toBeUndefined() - }) - - it('produces a matching CLI flag for every pre-SDK argv entry', async () => { - const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) - const spawns: SpawnSeen[] = [] - const launch = await resolvedLaunch('full-access', [ - '--model', - 'claude-sonnet-4-5', - '--dangerously-skip-permissions', - '--output-format', - 'text', - '--session-id=wrong-session', - '--add-dir', - '/repo/one', - '/repo/two', - '--add-dir=/repo/three' - ]) - 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) - // Keep the older bypass flag until user-installed CLIs have a compatible minimum version. - expect(argv.filter((arg) => arg === '--dangerously-skip-permissions')).toHaveLength(1) - expect(argv).not.toContain('--allow-dangerously-skip-permissions') - expect(argv[argv.indexOf('--permission-mode') + 1]).toBe('default') - expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5') - expect(argv.flatMap((arg, index) => (arg === '--add-dir' ? [argv[index + 1]] : []))).toEqual([ - '/repo/one', - '/repo/two', - '/repo/three' - ]) - expect(argv.filter((arg) => arg === '--model')).toHaveLength(1) - expect(argv.filter((arg) => arg === '--output-format')).toHaveLength(1) - expect(argv[argv.indexOf('--output-format') + 1]).toBe('stream-json') - // query implies print mode without emitting -p. - 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) - } - } - 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 () => { - 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 }, @@ -624,47 +314,4 @@ describe('Claude Agent SDK contract pins', () => { expect(interaction).toEqual(flags) }) }) - - 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() - expect(report.argv[0]).toBe(FAKE_CLI) - expect(report.execPath).toContain('node') - expect(report.controlRequests.some((frame) => frame.request.subtype === 'initialize')).toBe( - true - ) - }) }) - -it.each(['/installed/launcher', '/installed/launcher.mjs'])( - 'inserts the prefix at the real SDK spawn boundary for %s', - async (program) => { - let seen: { program: string; args?: readonly string[] } | undefined - const spawner = createClaudeCodeProcessSpawn( - (spec) => { - seen = spec - throw new Error('captured launcher contract') - }, - 'win32', - { pathToClaudeCodeExecutable: program, prefixArgs: ['run', '--', 'claude'] } - ) - await expect( - drainQuery({ pathToClaudeCodeExecutable: program, spawnClaudeCodeProcess: spawner.spawn }) - ).rejects.toThrow('captured launcher contract') - const prefixAt = program.endsWith('.mjs') ? 1 : 0 - expect(seen?.args?.slice(prefixAt, prefixAt + 3)).toEqual(['run', '--', 'claude']) - if (prefixAt) { - expect(seen?.args?.[0]).toBe(program) - } else { - expect(seen?.program).toBe(program) - } - expect(seen?.args?.slice(prefixAt + 3)).toContain('--output-format') - } -) diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts index 2784ec76eec..f2da6be21cf 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -1,19 +1,9 @@ -import { collectDescendantRows } from '../pty/process-retirement/pty-descendant-termination' -import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import type { PipedChildProcess } from '@orca/process-host/process-spec' import { execFileSync } from 'node:child_process' -import { PassThrough } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { spawnProcess } from '@orca/process-host' -import type { DescendantTreeVerdict } from '../pty/process-retirement/pty-descendant-exit-verification' -import type { DescendantSnapshot } from '../pty/process-retirement/pty-descendant-termination' -import { - createClaudeChildTreeReaper, - proveClaudeChildExit, - type ClaudeChildTreeReaper -} from './claude-agent-sdk-exit-proof' +import { proveClaudeChildExit } from './claude-agent-sdk-exit-proof' import { managedChild } from './claude-child-exit-proof-fixture' -import { GRACEFUL_EXIT_MS } from './claude-child-exit-proof-ladder' function childWithDescendantScript(input: { rootTrapsSigterm: boolean @@ -64,6 +54,7 @@ function descendantState(pid: number): 'running' | 'exited' { // ps start times have second precision; cross that boundary so the snapshot permits forced signals. function ageDescendantPastTheCaptureSecond(): Promise { + // ast-grep-ignore: test-no-wall-clock -- ps reports native process birth times on this clock. return new Promise((resolve) => setTimeout(resolve, 1_000 - (Date.now() % 1_000) + 20)) } @@ -96,47 +87,6 @@ function firstStdoutLine(child: PipedChildProcess): Promise { }) } -function mockChild(pid: number | null = 424242) { - return Object.assign(createFakePipedChild(), { - pid: pid ?? undefined, - stdin: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn(() => true) - }) -} - -function mockTree( - verdicts: DescendantTreeVerdict[], - forcedReapAttempted = false -): ClaudeChildTreeReaper & { - capture: ReturnType - reap: ReturnType -} { - let treeVerdict: DescendantTreeVerdict = 'unverifiable' - return { - forcedReapAttempted, - capture: vi.fn(async () => {}), - reap: vi.fn(async () => { - treeVerdict = verdicts.shift() ?? treeVerdict - return treeVerdict - }), - get treeVerdict() { - return treeVerdict - } - } -} - -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 - } -} - 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', @@ -223,644 +173,10 @@ describe('claude child exit proof', () => { 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 managed = managedChild(child) - const tree = mockTree(['exited']) - let exitedWhenArmed: boolean | null = null - tree.capture.mockImplementation(async () => { - exitedWhenArmed = managed.rootVerdict === 'exited' - }) - - // The fixture closes under POSIX rules; on Windows a self-exit after stdin end is the close. - await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(true) - expect(exitedWhenArmed).toBe(false) - expect(tree.reap).toHaveBeenCalledTimes(1) - expect(managed.rootVerdict).toBe('exited') - }, 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({ managed: managedChild(child) })).resolves.toBe(true) }, 20_000) - - it('reports an unprovable exit as false rather than assuming the child died', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - try { - const tree = mockTree(['exited']) - const proof = proveClaudeChildExit({ - managed: managedChild(mockChild()), - tree - }) - await new Promise((resolve) => setImmediate(resolve)) - await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS) - await vi.advanceTimersByTimeAsync(1_000) - - await expect(proof).resolves.toBe(false) - expect(tree.reap).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }, 20_000) - - it('reports false when the root exit was observed but a descendant was seen alive', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - try { - const child = mockChild() - const managed = managedChild(child) - const tree = mockTree(['live']) - tree.reap.mockImplementation(async () => { - child.emit('exit', null, 'SIGKILL') - return 'live' - }) - const proof = proveClaudeChildExit({ managed, tree }) - await new Promise((resolve) => setImmediate(resolve)) - await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS) - - await expect(proof).resolves.toBe(false) - expect(managed.rootVerdict).toBe('exited') - expect(tree.reap).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }, 20_000) - - it('re-verifies an unproven tree on a retried close instead of trusting the dead root', async () => { - const child = mockChild() - const managed = managedChild(child) - child.emit('exit', 0, null) - const tree = mockTree(['exited']) - - await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(true) - expect(tree.reap).toHaveBeenCalledTimes(1) - }) - - it('on Windows re-asks the tree on a retried close once a forced reap has run', async () => { - const child = mockChild() - const managed = managedChild(child, 'win32') - child.emit('exit', 0, null) - // The earlier close forced a reap whose taskkill failed; the root has since exited. - const tree = mockTree(['unverifiable'], true) - - await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(false) - expect(tree.reap).toHaveBeenCalledTimes(1) - }) - - it('stays unproven for a root that left before any snapshot could be armed', async () => { - const child = mockChild() - const managed = managedChild(child) - child.emit('exit', 0, null) - const captureDescendants = vi.fn(async () => snapshotOf(4243)) - const terminateDescendants = vi.fn() - const tree = createClaudeChildTreeReaper(child, { - platform: 'darwin', - exited: () => true, - captureDescendants, - terminateDescendants - }) - - await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(false) - 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() - 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() - 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() - 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, - // A retained row must keep its original capture boundary. - 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') - expect(terminateDescendants).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() - 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 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') - expect(child.kill).toHaveBeenCalledTimes(1) - exited = true - await expect(tree.reap()).resolves.toBe('unverifiable') - expect(captureDescendants).toHaveBeenCalledTimes(1) - 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') - 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('kills a held Windows root with one taskkill and never snapshots it', async () => { - const child = mockChild() - const release = Promise.withResolvers() - const terminateWindowsTree = vi.fn(() => release.promise) - const captureDescendants = vi.fn() - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - captureDescendants, - terminateWindowsTree - }) - - await tree.capture() - await tree.refresh?.() - const reap = tree.reap() - await vi.waitFor(() => expect(terminateWindowsTree).toHaveBeenCalledWith(424242)) - expect(child.kill).not.toHaveBeenCalled() - release.resolve(true) - await expect(reap).resolves.toBe('exited') - expect(tree.killDelivered).toBe(true) - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - expect(captureDescendants).not.toHaveBeenCalled() - }) - - it('reports a Windows tree unverifiable when taskkill does not exit cleanly', async () => { - const child = mockChild() - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - terminateWindowsTree: vi.fn(async () => false) - }) - - await expect(tree.reap()).resolves.toBe('unverifiable') - expect(tree.treeVerdict).toBe('unverifiable') - expect(tree.killDelivered).toBe(false) - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - }) - - it('reports a Windows tree unverifiable when taskkill could not run', async () => { - const child = mockChild() - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - terminateWindowsTree: vi.fn(async () => { - throw new Error('taskkill: spawn failed') - }) - }) - - await expect(tree.reap()).resolves.toBe('unverifiable') - expect(tree.killDelivered).toBe(false) - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - }) - - it('never taskkills a Windows root that already exited', async () => { - const child = mockChild() - const terminateWindowsTree = vi.fn(async () => true) - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - exited: () => true, - terminateWindowsTree - }) - - await expect(tree.reap()).resolves.toBe('unverifiable') - expect(terminateWindowsTree).not.toHaveBeenCalled() - expect(child.kill).not.toHaveBeenCalled() - }) - - 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() - }) -}) - -it.each([true, false])( - 'retains shared reaper taskkill=%s when root cleanup throws', - async (delivered) => { - const child = mockChild() - child.kill.mockImplementation(() => { - throw new Error('held root cleanup failed') - }) - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - terminateWindowsTree: async () => delivered - }) - await expect(tree.reap()).resolves.toBe(delivered ? 'exited' : 'unverifiable') - expect(tree.killDelivered).toBe(delivered) - } -) - -it.each([10, 100])( - 'shares one fresh forced snapshot across %s rows and callers', - async (population) => { - const child = mockChild() - const root = { pid: 424242, ppid: 1, pgid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' } - const writer = { ...root, pid: 424243, ppid: root.pid, pgid: 424243 } - const rows = [ - root, - writer, - ...Array.from({ length: population - 2 }, (_, index) => ({ - ...root, - pid: 424244 + index, - ppid: index === 0 ? root.pid : index % 2 ? writer.pid : 1, - pgid: writer.pid - })) - ] - const fresh = Promise.withResolvers() - const capture = vi.fn(async () => - collectDescendantRows( - root.pid, - rows.filter((row) => row !== writer), - 1 - ) - ) - const terminate = vi.fn(async () => 'unverifiable' as const) - const signal = vi.spyOn(process, 'kill').mockReturnValue(true) - try { - const tree = createClaudeChildTreeReaper(child, { - platform: 'linux', - supervised: true, - exited: () => false, - captureDescendants: capture, - terminateDescendants: terminate - }) - await tree.capture() - capture.mockImplementation(async () => { - expect(child.kill).toHaveBeenCalledWith('SIGSTOP') - await fresh.promise - return collectDescendantRows(root.pid, rows, 2) - }) - const first = tree.reap() - for (let index = 0; index < population; index++) { - expect(tree.reap()).toBe(first) - } - await vi.waitFor(() => expect(capture).toHaveBeenCalledTimes(2)) - fresh.resolve() - await first - expect(capture).toHaveBeenCalledTimes(2) - expect(terminate).toHaveBeenCalledOnce() - expect(signal.mock.calls.length).toBeGreaterThan(0) - expect(signal.mock.calls.length).toBeLessThanOrEqual(2) - expect( - signal.mock.calls.every(([pid, sig]) => pid === -writer.pid && sig === 'SIGKILL') - ).toBe(true) - expect(tree.killDelivered).toBe(true) - } finally { - signal.mockRestore() - } - } -) diff --git a/src/main/claude/claude-background-task-row-journal.test.ts b/src/main/claude/claude-background-task-row-journal.test.ts deleted file mode 100644 index 9bd836b6202..00000000000 --- a/src/main/claude/claude-background-task-row-journal.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session/journal/agent-session-journal-types' -import { describe, expect, it, vi } from 'vitest' -import type { - StructuredAgentSessionEventSink, - StructuredAgentSessionLifecycleJournal -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle' -import { - ClaudeBackgroundTaskIdentityResolver, - writeClaudeBackgroundTaskRow -} from './claude-background-task-row-journal' -import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' -import { START_BASH } from './claude-background-task-row-test-support' - -function journal( - epoch: () => string, - visitItems = vi.fn() -): StructuredAgentSessionLifecycleJournal { - return { - get epoch() { - return epoch() - }, - visitItems - } -} - -function row(): ClaudeBackgroundTaskRow { - return { - block: { - type: 'background-task', - taskId: 'task-1', - kind: 'command', - label: 'Build', - state: 'blocked', - error: 'exit 1' - }, - lastSerialized: null, - toolUseId: 'tool-1', - terminalNotificationReceived: true, - generation: 1 - } -} - -describe('Claude background task row journal', () => { - it('resolves one immutable run once per journal epoch', () => { - let epoch = 'epoch-1' - const visits = vi.fn() - const firstJournal = journal(() => epoch, visits) - const resolver = new ClaudeBackgroundTaskIdentityResolver() - - for (let revision = 0; revision < 100; revision += 1) { - resolver.resolve(firstJournal, 'task-1', 'tool-1') - } - expect(visits).toHaveBeenCalledOnce() - - resolver.resolve(firstJournal, 'task-1', 'tool-2') - expect(visits).toHaveBeenCalledTimes(2) - - epoch = 'epoch-2' - resolver.resolve(firstJournal, 'task-1', 'tool-1') - expect(visits).toHaveBeenCalledTimes(3) - - resolver.resolve( - journal(() => 'epoch-2', visits), - 'task-1', - 'tool-1' - ) - expect(visits).toHaveBeenCalledTimes(4) - }) - - it('bounds resolved run identities with LRU eviction', () => { - const visits = vi.fn() - const boundJournal = journal(() => 'epoch-1', visits) - const resolver = new ClaudeBackgroundTaskIdentityResolver() - - for (let index = 0; index < 513; index += 1) { - resolver.resolve(boundJournal, `task-${index}`, `tool-${index}`) - } - resolver.resolve(boundJournal, 'task-0', 'tool-0') - - expect(visits).toHaveBeenCalledTimes(514) - }) - - it('records a revision only after its append and publication are admitted', () => { - const task = row() - const appendAndPublish = vi - .fn() - .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) - .mockReturnValueOnce({ accepted: true }) - const sink = { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: vi.fn(), - tryAppendResolvedItemAndPublish: appendAndPublish - } - const resolver = new ClaudeBackgroundTaskIdentityResolver() - - expect( - writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) - ).toEqual({ - accepted: false, - reason: 'backpressure' - }) - expect(task.lastSerialized).toBeNull() - expect( - writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) - ).toEqual({ - accepted: true - }) - expect(task.lastSerialized).not.toBeNull() - expect(appendAndPublish).toHaveBeenCalledTimes(2) - }) - - it('appends the row, then publishes it', () => { - const calls: ('append' | 'publish')[] = [] - const task = row() - const sink: StructuredAgentSessionEventSink = { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: vi.fn(), - tryAppendResolvedItem: vi.fn(() => { - calls.push('append') - return { accepted: true as const } - }), - tryPublish: vi.fn(() => { - calls.push('publish') - return { accepted: true as const } - }) - } - - expect( - writeClaudeBackgroundTaskRow( - sink, - new ClaudeBackgroundTaskIdentityResolver(), - 'task-1', - task, - () => AGENT_JOURNAL_THREAD_SCOPE - ) - ).toEqual({ accepted: true }) - expect(calls).toEqual(['append', 'publish']) - expect(task.lastSerialized).not.toBeNull() - }) - - it('uses reserved lifecycle capacity when provider exit settles a live row', () => { - const appendAndPublish = vi.fn< - NonNullable - >(() => ({ accepted: true as const })) - const rows = new ClaudeBackgroundTaskRows({ - sink: { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: vi.fn(), - tryAppendResolvedItemAndPublish: appendAndPublish - }, - isForwardedParentTool: () => true, - turnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - - rows.observe(START_BASH) - rows.settleSession() - - expect(appendAndPublish).toHaveBeenCalledTimes(2) - expect(appendAndPublish.mock.calls[1]?.[3]).toMatchObject({ lifecycle: true }) - }) - - it('promotes a backpressured terminal row to lifecycle capacity on provider exit', () => { - const appendAndPublish = vi - .fn>() - .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) - .mockReturnValueOnce({ accepted: true }) - const rows = new ClaudeBackgroundTaskRows({ - sink: { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: vi.fn(), - tryAppendResolvedItemAndPublish: appendAndPublish - }, - isForwardedParentTool: () => true, - turnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'terminal-at-exit', - status: 'failed', - summary: 'failed' - }) - rows.settleSession() - - expect(appendAndPublish).toHaveBeenCalledTimes(2) - expect(appendAndPublish.mock.calls[1]?.[3]).toMatchObject({ lifecycle: true }) - }) - - it('retries a refused row before later provider messages are read', () => { - const appendAndPublish = vi - .fn() - .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) - .mockReturnValueOnce({ accepted: true }) - const rows = new ClaudeBackgroundTaskRows({ - sink: { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: vi.fn(), - tryAppendResolvedItemAndPublish: appendAndPublish - }, - isForwardedParentTool: () => true, - turnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - - expect(rows.observe(START_BASH)).toBe(true) - expect(rows.retryPendingWrites()).toEqual({ accepted: true }) - expect(appendAndPublish).toHaveBeenCalledTimes(2) - expect(rows.retryPendingWrites()).toEqual({ accepted: true }) - expect(appendAndPublish).toHaveBeenCalledTimes(2) - }) - - it('abandons a permanently failed retry and reports recovery instead of latching', () => { - const onPersistenceFailure = vi.fn() - const appendAndPublish = vi - .fn() - .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) - .mockReturnValueOnce({ accepted: false, reason: 'failed' }) - const rows = new ClaudeBackgroundTaskRows({ - sink: { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: vi.fn(), - tryAppendResolvedItemAndPublish: appendAndPublish - }, - isForwardedParentTool: () => true, - onPersistenceFailure, - turnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - - rows.observe(START_BASH) - expect(rows.retryPendingWrites()).toEqual({ accepted: false, reason: 'failed' }) - expect(onPersistenceFailure).toHaveBeenCalledOnce() - expect(rows.retryPendingWrites()).toEqual({ accepted: true }) - }) - - it('hands retry-capacity exhaustion to session recovery without throwing', () => { - const onPersistenceFailure = vi.fn() - const rows = new ClaudeBackgroundTaskRows({ - sink: { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: vi.fn(), - tryAppendResolvedItemAndPublish: vi.fn(() => ({ - accepted: false as const, - reason: 'backpressure' as const - })) - }, - isForwardedParentTool: () => true, - onPersistenceFailure, - turnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - - for (let index = 0; index < 512; index += 1) { - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: `task-${index}`, - status: 'failed', - summary: 'failed' - }) - } - expect(() => { - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'task-overflow', - status: 'failed', - summary: 'failed' - }) - }).not.toThrow() - expect(onPersistenceFailure).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'claude background task journal retry capacity exhausted' - }) - ) - }) - - it.each(['closed', 'failed'] as const)('surfaces a %s sink refusal', (reason) => { - const task = row() - const sink = { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: vi.fn(), - tryAppendResolvedItemAndPublish: vi.fn(() => ({ accepted: false as const, reason })) - } - - expect( - writeClaudeBackgroundTaskRow( - sink, - new ClaudeBackgroundTaskIdentityResolver(), - 'task-1', - task, - () => AGENT_JOURNAL_THREAD_SCOPE - ) - ).toEqual({ accepted: false, reason }) - expect(task.lastSerialized).toBeNull() - }) -}) diff --git a/src/main/claude/claude-background-task-rows.test.ts b/src/main/claude/claude-background-task-rows.test.ts deleted file mode 100644 index 584f9cb8dab..00000000000 --- a/src/main/claude/claude-background-task-rows.test.ts +++ /dev/null @@ -1,680 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - FAILED_NOTIFICATION, - FAILED_UPDATE, - FORWARDED_TOOL, - harness, - START_BASH -} from './claude-background-task-row-test-support' - -describe('claude background task rows', () => { - it('reports one failed backgrounded command as ONE row carrying the provider sentence', () => { - const { rows, items, latest, latestTwin } = harness() - rows.observe(START_BASH) - rows.observe(FAILED_UPDATE) - rows.observe(FAILED_NOTIFICATION) - - const identities = new Set( - items.map((item) => - item.identity.provider === 'orca' ? item.identity.clientMessageId : item.identity.provider - ) - ) - expect([...identities]).toEqual(['claude-background-task:byjnee2no']) - expect(latest()).toMatchObject({ - type: 'background-task', - taskId: 'byjnee2no', - kind: 'command', - label: 'Wait for the verification verdict', - state: 'blocked', - summary: FAILED_NOTIFICATION.summary, - outputFile: FAILED_NOTIFICATION.output_file - }) - expect(latestTwin()).toBe(FAILED_NOTIFICATION.summary) - expect(latestTwin()).not.toContain('task_notification') - }) - - it('lands the captured failure on its row when the announcement carried no tool id', () => { - const { rows, latestTwin } = harness() - rows.observe({ - type: 'system', - subtype: 'task_started', - task_id: 'bo2vuy8qb', - task_type: 'local_bash', - description: 'verify', - is_backgrounded: true - }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'bo2vuy8qb', - status: 'failed', - output_file: '', - summary: "Check the verifier's state" - }) - expect(latestTwin()).toBe("Check the verifier's state") - }) - - it('keeps terminal ownership after a tracked task reports foregrounded', () => { - const { rows, latest } = harness() - rows.observe(START_BASH) - rows.observe({ - type: 'system', - subtype: 'task_updated', - task_id: START_BASH.task_id, - patch: { is_backgrounded: false, status: 'running' } - }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: START_BASH.task_id, - status: 'failed', - summary: 'foreground transition failed' - }) - expect(latest()).toMatchObject({ state: 'blocked', summary: 'foreground transition failed' }) - }) - - it('revises in place rather than opening a row from a patch', () => { - const { rows, items, latest } = harness() - rows.observe({ - type: 'system', - subtype: 'task_updated', - task_id: 'never-announced', - patch: { status: 'running' } - }) - expect(items).toEqual([]) - rows.observe(START_BASH) - rows.observe({ - type: 'system', - subtype: 'task_progress', - task_id: 'byjnee2no', - description: 'Running Bash', - usage: { total_tokens: 1_200 } - }) - expect(latest()).toMatchObject({ label: 'Wait for the verification verdict', tokens: 1_200 }) - }) - - it('takes no row from a terminal patch for an untracked task', () => { - const { rows, items } = harness() - expect( - rows.observe({ - type: 'system', - subtype: 'task_updated', - task_id: 'pre-journal', - patch: { status: 'failed', description: 'Check logs', error: 'boom' } - }) - ).toBe(true) - expect(items).toEqual([]) - }) - - it('latches a reported outcome against a later live tick', () => { - const { rows, latest } = harness() - rows.observe(START_BASH) - rows.observe(FAILED_NOTIFICATION) - rows.observe({ - type: 'system', - subtype: 'background_tasks_changed', - tasks: [{ task_id: 'byjnee2no', task_type: 'local_bash', description: 'still listed' }] - }) - expect(latest()).toMatchObject({ state: 'blocked' }) - }) - - it('ignores late revisions after a task has reported its outcome', () => { - const { rows, items, latest, turnOpens } = harness() - rows.observe(START_BASH) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: START_BASH.task_id, - status: 'failed', - summary: 'first run failed' - }) - const writes = items.length - const opens = turnOpens.length - - rows.observe({ - type: 'system', - subtype: 'task_progress', - task_id: START_BASH.task_id, - usage: { total_tokens: 99 } - }) - rows.observe({ - type: 'system', - subtype: 'task_updated', - task_id: START_BASH.task_id, - patch: { error: 'late update' } - }) - rows.observe({ - type: 'system', - subtype: 'background_tasks_changed', - tasks: [{ task_id: START_BASH.task_id, task_type: 'local_bash', description: 'late roster' }] - }) - rows.observe({ ...START_BASH, description: 'duplicate start' }) - - expect(items).toHaveLength(writes) - expect(turnOpens).toHaveLength(opens) - expect(latest()).toMatchObject({ state: 'blocked', summary: 'first run failed' }) - }) - - it('does not reopen a turn when a settled session receives a late outcome', () => { - const { rows, latest, turnOpens } = harness() - rows.observe(START_BASH) - rows.settleSession() - const opens = turnOpens.length - - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: START_BASH.task_id, - status: 'failed', - summary: 'late outcome' - }) - - expect(turnOpens).toHaveLength(opens) - expect(latest()).toMatchObject({ state: 'blocked', summary: 'late outcome' }) - }) - - it('reopens a settled row when Claude re-announces the same task id with a new tool id', () => { - const { rows, latest } = harness() - rows.observe({ ...START_BASH, task_id: 'resume-1', tool_use_id: 'toolu_first' }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'resume-1', - tool_use_id: 'toolu_first', - status: 'completed', - summary: 'first run finished' - }) - expect(latest()).toMatchObject({ state: 'done', summary: 'first run finished' }) - - rows.observe({ - ...START_BASH, - task_id: 'resume-1', - tool_use_id: 'toolu_second', - status: 'running', - description: 'Second run' - }) - expect(latest()).toMatchObject({ state: 'working', label: 'Second run' }) - expect(latest()).not.toHaveProperty('summary') - - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'resume-1', - tool_use_id: 'toolu_second', - status: 'failed', - summary: 'second run failed' - }) - expect(latest()).toMatchObject({ state: 'blocked', summary: 'second run failed' }) - }) - - // Aggregate entries carry identity fields without per-task status. - it('takes identity from the aggregate roster', () => { - const { rows, latest } = harness() - rows.observe({ ...START_BASH, task_id: 'aggregate-1', description: undefined }) - expect(latest()).toMatchObject({ label: '', state: 'working' }) - - rows.observe({ - type: 'system', - subtype: 'background_tasks_changed', - tasks: [{ task_id: 'aggregate-1', task_type: 'local_bash', description: 'Named by roster' }] - }) - - expect(latest()).toMatchObject({ label: 'Named by roster', state: 'working' }) - }) - - it('does not let mere presence in the live set revive a settled row', () => { - const { rows, latest } = harness() - rows.observe({ ...START_BASH, task_id: 'aggregate-2' }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'aggregate-2', - status: 'completed' - }) - expect(latest()).toMatchObject({ state: 'done' }) - - rows.observe({ - type: 'system', - subtype: 'background_tasks_changed', - tasks: [{ task_id: 'aggregate-2', task_type: 'local_bash', description: 'still listed' }] - }) - - expect(latest()).toMatchObject({ state: 'done' }) - }) - - it('excludes ambient housekeeping from the aggregate roster', () => { - const { rows, latest } = harness() - rows.observe({ ...START_BASH, task_id: 'aggregate-3', description: undefined }) - rows.observe({ - type: 'system', - subtype: 'background_tasks_changed', - tasks: [ - { - task_id: 'aggregate-3', - task_type: 'local_bash', - description: 'housekeeping name', - ambient: true - } - ] - }) - expect(latest()).toMatchObject({ label: '' }) - }) - - it('never burns a revision on a duplicate delivery', () => { - const { rows, items } = harness() - rows.observe(START_BASH) - const afterStart = items.length - rows.observe(START_BASH) - expect(items.length).toBe(afterStart) - }) - - it('claims a resumed task announced under a new tool id', () => { - const { rows, items, latest } = harness() - rows.observe(START_BASH) - rows.observe({ - type: 'system', - subtype: 'task_notification', - tool_use_id: 'toolu_01CqPd7y', - status: 'failed', - summary: 'it failed' - }) - expect(items.at(-1)?.identity).toMatchObject({ - clientMessageId: 'claude-background-task:byjnee2no' - }) - expect(latest()).toMatchObject({ taskId: 'byjnee2no', state: 'blocked' }) - }) - - it('rejects overlong tool-use aliases instead of clipping them into collisions', () => { - const { rows, items, latest } = harness() - rows.observe({ ...START_BASH, task_id: 'task-a' }) - const afterStart = items.length - - expect( - rows.observe({ - type: 'system', - subtype: 'task_notification', - tool_use_id: `${'x'.repeat(512)}A`, - status: 'failed', - summary: 'misattributed failure' - }) - ).toBe(false) - expect(items).toHaveLength(afterStart) - expect(latest()).toMatchObject({ taskId: 'task-a', state: 'working' }) - }) - - it('evicts settled rows so the lifetime cap cannot drop a later failure', () => { - const { rows, latest } = harness() - for (let index = 0; index < 64; index += 1) { - rows.observe({ ...START_BASH, task_id: `settled-${index}` }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: `settled-${index}`, - status: 'completed' - }) - } - - rows.observe({ ...START_BASH, task_id: 'overflow' }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'overflow', - status: 'failed', - summary: 'overflow failed' - }) - - expect(latest()).toMatchObject({ - taskId: 'overflow', - state: 'blocked', - summary: 'overflow failed' - }) - }) - - it('reopens a settled task after its row was evicted when the parent alias changes', () => { - const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second']) - rows.observe({ ...START_BASH, task_id: 'evicted-restart' }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'evicted-restart', - tool_use_id: FORWARDED_TOOL, - status: 'completed' - }) - for (let index = 0; index < 63; index += 1) { - rows.observe({ ...START_BASH, task_id: `settled-${index}` }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: `settled-${index}`, - status: 'completed' - }) - } - rows.observe({ ...START_BASH, task_id: 'evictor' }) - rows.observe({ - ...START_BASH, - task_id: 'evicted-restart', - tool_use_id: 'toolu_second', - description: 'second invocation' - }) - - expect([...new Set(keys())]).toContain('claude-background-task:evicted-restart#2') - expect(latest()).toMatchObject({ - taskId: 'evicted-restart', - state: 'working', - label: 'second invocation' - }) - }) - - it('bounds generation history without reusing an evicted durable identity', () => { - const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second']) - rows.observe({ ...START_BASH, task_id: 'generation-reused' }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'generation-reused', - status: 'completed' - }) - rows.observe({ ...START_BASH, task_id: 'generation-reused', tool_use_id: 'toolu_second' }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'generation-reused', - tool_use_id: 'toolu_second', - status: 'completed' - }) - for (let index = 0; index < 513; index += 1) { - const taskId = `generation-${index}` - rows.observe({ ...START_BASH, task_id: taskId }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: taskId, - status: 'completed' - }) - } - - rows.observe({ ...START_BASH, task_id: 'generation-reused' }) - rows.observe({ - ...START_BASH, - task_id: 'generation-0', - tool_use_id: 'toolu_second', - description: 'reused after ledger eviction' - }) - - const identities = new Set(keys()) - expect(identities).toContain('claude-background-task:generation-reused#2') - expect(identities).toContain('claude-background-task:generation-reused#4') - expect(identities).toContain('claude-background-task:generation-0') - expect(identities).toContain('claude-background-task:generation-0#5') - expect(latest()).toMatchObject({ - taskId: 'generation-0', - state: 'working', - label: 'reused after ledger eviction' - }) - - expect(rows.ledgerSizes.generations).toBeLessThanOrEqual(512) - }) - - it('declines coverage so the fallback still reports when every row slot is live', () => { - const { rows } = harness() - for (let index = 0; index < 64; index += 1) { - rows.observe({ ...START_BASH, task_id: `live-${index}` }) - } - expect(rows.observe({ ...START_BASH, task_id: 'overflow-live' })).toBe(false) - }) - - it('keeps one overflow terminal row across its update and notification', () => { - const { rows, keys, latest } = harness() - for (let index = 0; index < 64; index += 1) { - rows.observe({ ...START_BASH, task_id: `live-${index}` }) - } - - expect(rows.observe({ ...START_BASH, task_id: 'overflow-fallback' })).toBe(false) - expect( - rows.observe({ - type: 'system', - subtype: 'task_updated', - task_id: 'overflow-fallback', - patch: { status: 'failed' } - }) - ).toBe(true) - expect(keys().filter((id) => id === 'claude-background-task:overflow-fallback')).toHaveLength(1) - expect(latest()).toMatchObject({ state: 'blocked' }) - expect( - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'overflow-fallback', - status: 'failed', - summary: 'overflow failed' - }) - ).toBe(true) - expect( - rows.observe({ - type: 'system', - subtype: 'task_progress', - task_id: 'overflow-fallback', - usage: { total_tokens: 3 } - }) - ).toBe(true) - expect( - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'overflow-fallback', - status: 'failed', - summary: 'duplicate overflow failed' - }) - ).toBe(true) - expect(keys().filter((id) => id === 'claude-background-task:overflow-fallback')).toHaveLength(2) - expect(latest()).toMatchObject({ state: 'blocked', summary: 'overflow failed' }) - }) - - it('lets the final notification correct a provisional failed update', () => { - const { rows, latest, latestTwin } = harness() - rows.observe(START_BASH) - rows.observe({ ...FAILED_UPDATE, task_id: START_BASH.task_id }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: START_BASH.task_id, - status: 'stopped', - summary: 'No completion record was found' - }) - - expect(latest()).toMatchObject({ state: 'idle', summary: 'No completion record was found' }) - expect(latestTwin()).toBe('No completion record was found') - }) - - it('corrects a capacity-refused failed update with the final stopped verdict', () => { - const { rows, latest, keys } = harness() - for (let index = 0; index < 64; index += 1) { - rows.observe({ ...START_BASH, task_id: `live-${index}` }) - } - rows.observe({ ...START_BASH, task_id: 'overflow-stopped' }) - rows.observe({ - type: 'system', - subtype: 'task_updated', - task_id: 'overflow-stopped', - patch: { status: 'failed' } - }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'overflow-stopped', - status: 'stopped', - summary: 'No completion record was found' - }) - - expect(latest()).toMatchObject({ state: 'idle', summary: 'No completion record was found' }) - expect(keys().filter((id) => id === 'claude-background-task:overflow-stopped')).toHaveLength(2) - }) - - it('preserves a fallback run alias across a duplicate terminal without one', () => { - const { rows, latest } = harness([FORWARDED_TOOL, 'toolu_second']) - for (let index = 0; index < 64; index += 1) { - rows.observe({ ...START_BASH, task_id: `live-${index}` }) - } - - expect(rows.observe({ ...START_BASH, task_id: 'overflow-restart' })).toBe(false) - expect( - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'overflow-restart', - tool_use_id: FORWARDED_TOOL, - status: 'completed' - }) - ).toBe(true) - expect( - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'overflow-restart', - status: 'completed' - }) - ).toBe(true) - - // Free a slot; retain the prior terminal alias so the new invocation can prove a restart. - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'live-0', - status: 'completed' - }) - expect( - rows.observe({ - ...START_BASH, - task_id: 'overflow-restart', - tool_use_id: 'toolu_second', - description: 'second overflow run' - }) - ).toBe(true) - expect(latest()).toMatchObject({ - taskId: 'overflow-restart', - state: 'working', - label: 'second overflow run' - }) - }) - - it('bounds foreign-owner memory for tasks rendered elsewhere', () => { - const { rows } = harness() - for (let index = 0; index < 600; index += 1) { - rows.observe({ - type: 'system', - subtype: 'task_started', - task_id: `agent-${index}`, - task_type: 'local_agent', - subagent_type: 'explorer' - }) - } - - expect(rows.ledgerSizes.foreign).toBeLessThanOrEqual(512) - }) - - it('bounds fallback ownership memory for capacity-refused tasks', () => { - const { rows } = harness() - for (let index = 0; index < 64; index += 1) { - rows.observe({ ...START_BASH, task_id: `live-${index}` }) - } - for (let index = 0; index < 600; index += 1) { - rows.observe({ ...START_BASH, task_id: `overflow-${index}` }) - } - - expect(rows.ledgerSizes.fallbackTaskIds).toBeLessThanOrEqual(512) - }) - - it('bounds settled overflow rows while preserving the evicted outcome', () => { - const { rows, keys } = harness() - for (let index = 0; index < 64; index += 1) { - rows.observe({ ...START_BASH, task_id: `live-${index}` }) - } - for (let index = 0; index < 513; index += 1) { - const id = `overflow-${index}` - rows.observe({ ...START_BASH, task_id: id }) - rows.observe({ - type: 'system', - subtype: 'task_updated', - task_id: id, - patch: { status: 'failed' } - }) - } - const beforeRedelivery = keys().length - expect(rows.ledgerSizes.overflowTerminalRows).toBeLessThanOrEqual(512) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'overflow-0', - status: 'failed', - summary: 'late outcome' - }) - expect(keys()).toHaveLength(beforeRedelivery) - expect(keys()).toContain('claude-background-task:overflow-0') - }) - - it('loses contact rather than claiming an outcome when the provider goes away', () => { - const { rows, latest, latestTwin } = harness() - rows.observe(START_BASH) - rows.dispose() - expect(latest()).toMatchObject({ state: 'unverifiable' }) - // Older clients read this sentence without understanding the typed task block. - expect(latestTwin()).toBe( - 'Background command "Wait for the verification verdict" stopped reporting' - ) - }) - - it('states only that a live task was started, never that it is still running', () => { - const { rows, latestTwin } = harness() - rows.observe(START_BASH) - expect(latestTwin()).toBe('Started background command "Wait for the verification verdict"') - }) - - it('declines frames it does not own', () => { - const { rows } = harness() - expect(rows.observe({ type: 'system', subtype: 'init' })).toBe(false) - expect(rows.observe({ type: 'assistant' })).toBe(false) - expect(rows.observe({ type: 'system', subtype: 'task_started', task_id: 'x' })).toBe(true) - }) - it('admits a task type it does not recognise as no task at all', () => { - const { rows, items } = harness() - rows.observe({ ...START_BASH, task_id: 'weird-1', task_type: 'local_teleport' }) - expect(items).toEqual([]) - }) - - it('gives a reused task id a fresh row instead of overwriting the finished run', () => { - const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second_run']) - rows.observe(START_BASH) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'byjnee2no', - status: 'failed', - summary: 'first run failed' - }) - rows.observe({ ...START_BASH, tool_use_id: 'toolu_second_run' }) - const written = [...new Set(keys())] - expect(written).toEqual([ - 'claude-background-task:byjnee2no', - 'claude-background-task:byjnee2no#2' - ]) - expect(latest()).toMatchObject({ state: 'working', parentToolUseId: 'toolu_second_run' }) - }) - - it('carries the spawning tool call on the row', () => { - const { rows, latest } = harness() - rows.observe(START_BASH) - expect(latest()).toMatchObject({ parentToolUseId: FORWARDED_TOOL }) - }) - - it('does not re-open a task that is already running', () => { - // Redelivery cannot revise metadata on an already visible run. - const { rows, items, latest } = harness() - rows.observe({ ...START_BASH, description: undefined }) - const afterStart = items.length - expect(latest()).toMatchObject({ label: '', state: 'working' }) - - rows.observe({ ...START_BASH, description: 'Named on redelivery' }) - expect(items.length).toBe(afterStart) - expect(latest()).toMatchObject({ label: '' }) - }) -}) diff --git a/src/main/claude/claude-background-task-tracker.test.ts b/src/main/claude/claude-background-task-tracker.test.ts deleted file mode 100644 index aee0ef57897..00000000000 --- a/src/main/claude/claude-background-task-tracker.test.ts +++ /dev/null @@ -1,703 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ClaudeBackgroundTaskTracker, - classifyClaudeBackgroundTaskKind -} from './claude-background-task-tracker' - -function system(subtype: string, fields: Record): Record { - return { type: 'system', subtype, session_id: 'provider-1', uuid: crypto.randomUUID(), ...fields } -} - -function result(): Record { - return { type: 'result', subtype: 'success', session_id: 'provider-1', uuid: crypto.randomUUID() } -} - -function aggregate(tasks: unknown[]): Record { - return system('background_tasks_changed', { tasks }) -} - -function trackerAt(times: number[]): ClaudeBackgroundTaskTracker { - let index = 0 - return new ClaudeBackgroundTaskTracker(() => times[Math.min(index++, times.length - 1)]) -} - -function rows(tracker: ClaudeBackgroundTaskTracker): { id: string; stoppable?: boolean }[] { - return (tracker.state?.tasks ?? []).map((task) => ({ - id: task.id, - ...(task.stoppable === undefined ? {} : { stoppable: task.stoppable }) - })) -} - -function started(id: string, backgrounded: boolean): Record { - return system('task_started', { - task_id: id, - task_type: 'local_agent', - is_backgrounded: backgrounded - }) -} - -describe('ClaudeBackgroundTaskTracker', () => { - it('classifies SDK task types without inferring them from descriptions', () => { - expect(classifyClaudeBackgroundTaskKind('local_agent')).toBe('agent') - expect(classifyClaudeBackgroundTaskKind('local_workflow')).toBe('workflow') - expect(classifyClaudeBackgroundTaskKind('local_bash')).toBe('command') - expect(classifyClaudeBackgroundTaskKind('monitor')).toBe('monitor') - expect(classifyClaudeBackgroundTaskKind('future_task')).toBe('unknown') - }) - - it('publishes a backgrounded task while the foreground turn is still running', () => { - const tracker = trackerAt([100]) - tracker.observe({ type: 'user' }, true) - expect( - tracker.observe( - system('task_started', { - task_id: 'task-1', - task_type: 'local_agent', - is_backgrounded: true - }) - ) - ).toBe(true) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'agent', state: 'working', startedAt: 100 }] - }) - - expect(tracker.observe(result())).toBe(false) - expect(tracker.state?.tasks).toHaveLength(1) - }) - - it('uses an explicit background update for a foreground task and ignores progress alone', () => { - const tracker = trackerAt([100]) - tracker.observe({ type: 'user' }, true) - tracker.observe( - system('task_started', { - task_id: 'task-1', - task_type: 'local_bash', - is_backgrounded: false - }) - ) - expect( - tracker.observe(system('task_progress', { task_id: 'task-1', description: 'still working' })) - ).toBe(false) - tracker.observe(result()) - expect(tracker.state).toBeNull() - - tracker.observe(system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } })) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'command', state: 'working', startedAt: 100 }] - }) - }) - - it('publishes bounded display details when a running task description changes', () => { - const tracker = trackerAt([100]) - expect( - tracker.observe( - system('task_started', { - task_id: 'task-1', - task_type: 'local_bash', - is_backgrounded: true, - description: ' run\n the build ' - }) - ) - ).toBe(true) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [ - { - id: 'task-1', - kind: 'command', - description: 'run the build', - state: 'working', - startedAt: 100 - } - ] - }) - - expect( - tracker.observe( - system('task_updated', { - task_id: 'task-1', - patch: { description: 'x'.repeat(600) } - }) - ) - ).toBe(true) - expect(tracker.state?.tasks?.[0]?.description).toHaveLength(512) - expect( - tracker.observe( - system('task_updated', { - task_id: 'task-1', - patch: { description: 'x'.repeat(600) } - }) - ) - ).toBe(false) - }) - - it('carries provider-reported names and re-derives classification per transition', () => { - const tracker = trackerAt([100]) - tracker.observe( - system('task_started', { - task_id: 'task-1', - task_type: 'future_task', - is_backgrounded: true - }) - ) - expect(tracker.state?.tasks?.[0]).toMatchObject({ kind: 'unknown' }) - - expect( - tracker.observe( - system('task_updated', { - task_id: 'task-1', - patch: { task_type: 'local_agent', agent_type: 'deep_review' } - }) - ) - ).toBe(true) - expect(tracker.state?.tasks?.[0]).toMatchObject({ - kind: 'agent', - name: 'deep_review', - state: 'working' - }) - }) - - it('retains settled siblings beside live work and exits with the last live task', () => { - const tracker = trackerAt([100, 200]) - tracker.observe( - system('task_started', { task_id: 'task-a', task_type: 'local_agent', is_backgrounded: true }) - ) - tracker.observe( - system('task_started', { task_id: 'task-b', task_type: 'local_agent', is_backgrounded: true }) - ) - - expect( - tracker.observe(system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } })) - ).toBe(true) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [{ id: 'task-b', kind: 'agent', state: 'working', startedAt: 200 }], - settledTasks: [{ id: 'task-a', kind: 'agent', state: 'done', startedAt: 100 }] - }) - expect(tracker.stoppableTaskIds).toEqual(['task-b']) - - expect( - tracker.observe(system('task_updated', { task_id: 'task-b', patch: { status: 'killed' } })) - ).toBe(true) - expect(tracker.state).toBeNull() - }) - - it('settles a sibling from the captured producer order: aggregate eviction, then the outcome', () => { - // Captured SDK order: roster removal precedes terminal edges. - const tracker = trackerAt([100, 200]) - tracker.observe( - system('task_started', { - task_id: 'bh4zn8der', - tool_use_id: 'toolu_01M', - description: 'Sleep for 5 seconds', - is_backgrounded: true, - task_type: 'local_bash' - }) - ) - tracker.observe( - aggregate([ - { task_id: 'bh4zn8der', task_type: 'local_bash', description: 'Sleep for 5 seconds' }, - { task_id: 'bprosaiim', task_type: 'local_bash', description: 'Sleep for 25 seconds' } - ]) - ) - - tracker.observe( - aggregate([ - { task_id: 'bprosaiim', task_type: 'local_bash', description: 'Sleep for 25 seconds' } - ]) - ) - tracker.observe( - system('task_updated', { - task_id: 'bh4zn8der', - patch: { status: 'completed', end_time: 1788804376515 } - }) - ) - expect( - tracker.observe( - system('task_notification', { - task_id: 'bh4zn8der', - tool_use_id: 'toolu_01M', - status: 'completed', - summary: 'Background command "Sleep for 5 seconds" completed (exit code 0)', - usage: { total_tokens: 18130, tool_uses: 1, duration_ms: 10772 } - }) - ) - ).toBe(true) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [ - { - id: 'bprosaiim', - kind: 'command', - description: 'Sleep for 25 seconds', - state: 'working', - startedAt: 200 - } - ], - settledTasks: [ - { - id: 'bh4zn8der', - kind: 'command', - description: 'Sleep for 5 seconds', - state: 'done', - startedAt: 100, - totalTokens: 18130 - } - ] - }) - - tracker.observe(aggregate([])) - tracker.observe(system('task_updated', { task_id: 'bprosaiim', patch: { status: 'killed' } })) - tracker.observe(system('task_notification', { task_id: 'bprosaiim', status: 'stopped' })) - expect(tracker.state).toBeNull() - }) - - it('carries task_progress usage into a live row without clobbering its name', () => { - const tracker = trackerAt([100]) - tracker.observe( - system('task_started', { - task_id: 'agent-1', - task_type: 'local_agent', - subagent_type: 'general-purpose', - description: 'Sleep 6 seconds test', - is_backgrounded: true - }) - ) - expect( - tracker.observe( - system('task_progress', { - task_id: 'agent-1', - description: 'Running Sleep for 6 seconds', - subagent_type: 'general-purpose', - usage: { total_tokens: 14866, tool_uses: 1, duration_ms: 2818 }, - last_tool_name: 'Bash' - }) - ) - ).toBe(true) - expect(tracker.state?.tasks?.[0]).toEqual({ - id: 'agent-1', - kind: 'agent', - description: 'Sleep 6 seconds test', - name: 'general-purpose', - state: 'working', - startedAt: 100, - totalTokens: 14866 - }) - }) - - it('maps terminal statuses onto settled states', () => { - const tracker = trackerAt([100, 200]) - tracker.observe( - system('task_started', { task_id: 'live', task_type: 'local_agent', is_backgrounded: true }) - ) - tracker.observe( - system('task_started', { task_id: 'failed', task_type: 'local_agent', is_backgrounded: true }) - ) - tracker.observe(system('task_notification', { task_id: 'failed', status: 'failed' })) - expect(tracker.state?.settledTasks).toEqual([ - { id: 'failed', kind: 'agent', state: 'blocked', startedAt: 200 } - ]) - }) - - it('leaves a task open when a patch cannot be read', () => { - const tracker = trackerAt([100]) - tracker.observe( - system('task_started', { task_id: 'task-1', task_type: 'local_agent', is_backgrounded: true }) - ) - expect(tracker.observe(system('task_updated', { task_id: 'task-1', patch: 'garbage' }))).toBe( - false - ) - expect(tracker.state?.tasks).toHaveLength(1) - expect(tracker.state?.settledTasks).toBeUndefined() - }) - - it('replaces its roster from aggregate lifecycle frames and preserves stoppable provider ids', () => { - const tracker = trackerAt([100]) - expect( - tracker.observe( - aggregate([ - { task_id: 'task-agent', task_type: 'local_agent', description: 'agent' }, - { task_id: 'task-bash', task_type: 'local_bash', description: 'bash' } - ]) - ) - ).toBe(true) - expect(tracker.stoppableTaskIds).toEqual(['task-agent', 'task-bash']) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [ - { id: 'task-agent', kind: 'agent', description: 'agent', state: 'working', startedAt: 100 }, - { id: 'task-bash', kind: 'command', description: 'bash', state: 'working', startedAt: 100 } - ] - }) - - expect( - tracker.observe( - aggregate([{ task_id: 'task-next', task_type: 'local_workflow', description: 'workflow' }]) - ) - ).toBe(true) - expect(tracker.stoppableTaskIds).toEqual(['task-next']) - - expect(tracker.observe(aggregate([]))).toBe(true) - expect(tracker.stoppableTaskIds).toEqual([]) - expect(tracker.state).toBeNull() - }) - - it('preserves first-seen timestamps across aggregate roster replacement', () => { - const tracker = trackerAt([100, 200]) - tracker.observe( - system('task_started', { task_id: 'task-1', task_type: 'local_agent', is_backgrounded: true }) - ) - tracker.observe( - aggregate([ - { task_id: 'task-1', task_type: 'local_agent' }, - { task_id: 'task-2', task_type: 'local_bash' } - ]) - ) - expect(tracker.state?.tasks).toEqual([ - { id: 'task-1', kind: 'agent', state: 'working', startedAt: 100 }, - { id: 'task-2', kind: 'command', state: 'working', startedAt: 200 } - ]) - }) - - it('excludes ambient aggregate tasks', () => { - const tracker = trackerAt([100]) - tracker.observe( - aggregate([ - { task_id: 'ambient', task_type: 'monitor', description: 'watcher', ambient: true }, - { task_id: 'visible', task_type: 'local_bash', description: 'command' } - ]) - ) - - expect(tracker.stoppableTaskIds).toEqual(['visible']) - }) - - it('does not let late edge frames revive tasks cleared by an aggregate roster', () => { - const tracker = trackerAt([100]) - tracker.observe( - aggregate([{ task_id: 'task-late', task_type: 'local_agent', description: 'agent' }]) - ) - tracker.observe(aggregate([])) - - tracker.observe( - system('task_started', { - task_id: 'task-late', - task_type: 'local_agent', - is_backgrounded: true - }) - ) - tracker.observe( - system('task_updated', { task_id: 'task-late', patch: { is_backgrounded: true } }) - ) - - expect(tracker.stoppableTaskIds).toEqual([]) - expect(tracker.state).toBeNull() - }) - - it('lets an authoritative aggregate roster replace earlier terminal-edge evidence', () => { - const tracker = trackerAt([100]) - tracker.observe(system('task_notification', { task_id: 'task-live', status: 'completed' })) - - tracker.observe( - aggregate([{ task_id: 'task-live', task_type: 'local_agent', description: 'agent' }]) - ) - - expect(tracker.stoppableTaskIds).toEqual(['task-live']) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [ - { id: 'task-live', kind: 'agent', description: 'agent', state: 'working', startedAt: 100 } - ] - }) - }) - - it('retracts a settled copy when an authoritative roster reports the task live again', () => { - const tracker = trackerAt([100, 200, 300]) - const tasks = [ - { task_id: 'agent', task_type: 'local_agent', description: 'Review sample' }, - { task_id: 'shell', task_type: 'local_bash' } - ] - tracker.observe(aggregate(tasks)) - tracker.observe(system('task_notification', { task_id: 'agent', status: 'completed' })) - expect(tracker.state?.settledTasks).toHaveLength(1) - tracker.observe(aggregate(tasks)) - expect(tracker.state?.tasks?.map((task) => task.id)).toEqual(['agent', 'shell']) - expect(tracker.state?.settledTasks).toBeUndefined() - }) - - it('keeps terminal edges authoritative on either side of aggregate replacement', () => { - const terminalFirst = trackerAt([100]) - terminalFirst.observe( - system('task_notification', { task_id: 'task-first', status: 'completed' }) - ) - terminalFirst.observe(aggregate([])) - terminalFirst.observe( - system('task_started', { - task_id: 'task-first', - task_type: 'local_agent', - is_backgrounded: true - }) - ) - expect(terminalFirst.state).toBeNull() - - const terminalLast = trackerAt([100]) - terminalLast.observe( - aggregate([{ task_id: 'task-last', task_type: 'local_agent', description: 'agent' }]) - ) - terminalLast.observe(system('task_notification', { task_id: 'task-last', status: 'completed' })) - terminalLast.observe( - system('task_started', { - task_id: 'task-last', - task_type: 'local_agent', - is_backgrounded: true - }) - ) - expect(terminalLast.state).toBeNull() - }) - - it('keeps terminal evidence authoritative across duplicates and out-of-order starts', () => { - const tracker = trackerAt([100]) - const terminal = system('task_notification', { task_id: 'task-late', status: 'completed' }) - tracker.observe(terminal) - tracker.observe(terminal) - tracker.observe( - system('task_started', { - task_id: 'task-late', - task_type: 'local_workflow', - is_backgrounded: true - }) - ) - expect(tracker.state).toBeNull() - - tracker.observe( - system('task_started', { - task_id: 'task-live', - task_type: 'monitor' - }) - ) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [{ id: 'task-live', kind: 'monitor', state: 'monitoring', startedAt: 100 }] - }) - expect( - tracker.observe(system('task_updated', { task_id: 'task-live', patch: { status: 'killed' } })) - ).toBe(true) - expect(tracker.state).toBeNull() - }) - - it('recognizes task types that are registered only as background work', () => { - for (const taskType of ['local_workflow', 'monitor']) { - const tracker = trackerAt([100]) - tracker.observe(system('task_started', { task_id: taskType, task_type: taskType })) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [ - { - id: taskType, - kind: taskType === 'local_workflow' ? 'workflow' : 'monitor', - state: taskType === 'local_workflow' ? 'working' : 'monitoring', - startedAt: 100 - } - ] - }) - } - }) - - it('admits unknown background updates conservatively and tracks every edge-only start', () => { - const tracker = trackerAt([100]) - tracker.observe( - system('task_updated', { task_id: 'unknown', patch: { is_backgrounded: true } }) - ) - expect(tracker.stoppableTaskIds).toEqual(['unknown']) - - for (let index = 0; index < 400; index += 1) { - tracker.observe( - system('task_started', { - task_id: `task-${index}`, - task_type: 'local_agent', - is_backgrounded: true - }) - ) - } - expect(tracker.stoppableTaskIds).toHaveLength(401) - tracker.observe(system('task_notification', { task_id: 'unknown', status: 'completed' })) - for (let index = 0; index < 399; index += 1) { - tracker.observe( - system('task_notification', { task_id: `task-${index}`, status: 'completed' }) - ) - } - expect(tracker.goalBackgroundWork).toBe('live') - tracker.observe(system('task_notification', { task_id: 'task-399', status: 'completed' })) - expect(tracker.goalBackgroundWork).toBe('exited') - }) - - it('lists every aggregate roster entry and resets to the edge-only fallback on clear', () => { - const tracker = trackerAt([100]) - tracker.observe( - aggregate( - Array.from({ length: 400 }, (_, index) => ({ - task_id: `aggregate-${index}`, - task_type: 'local_bash', - description: 'command' - })) - ) - ) - expect(tracker.stoppableTaskIds).toHaveLength(400) - - tracker.clear() - tracker.observe( - system('task_started', { - task_id: 'edge-after-reset', - task_type: 'local_agent', - is_backgrounded: true - }) - ) - expect(tracker.stoppableTaskIds).toEqual(['edge-after-reset']) - }) - - it('publishes an aggregate roster observed mid-turn', () => { - const tracker = trackerAt([100]) - tracker.observe({ type: 'user' }, true) - expect( - tracker.observe( - aggregate([{ task_id: 'task-live', task_type: 'local_bash', description: 'command' }]) - ) - ).toBe(true) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [ - { - id: 'task-live', - kind: 'command', - description: 'command', - state: 'working', - startedAt: 100 - } - ] - }) - }) - - it('ignores ambient SDK tasks and clears all liveness when the session ends', () => { - const tracker = trackerAt([100]) - tracker.observe( - system('task_started', { - task_id: 'ambient', - task_type: 'monitor', - is_backgrounded: true, - ambient: true - }) - ) - expect(tracker.state).toBeNull() - tracker.observe( - system('task_started', { - task_id: 'task-live', - task_type: 'local_agent', - is_backgrounded: true - }) - ) - expect(tracker.clear()).toBe(true) - expect(tracker.state).toBeNull() - }) - it('marks a foreground row not stoppable and leaves a backgrounded row alone', () => { - const tracker = new ClaudeBackgroundTaskTracker() - tracker.observe({ type: 'user' }, true) - tracker.observe(started('fore-1', false)) - tracker.observe(started('back-1', true)) - - expect(rows(tracker)).toEqual([{ id: 'fore-1', stoppable: false }, { id: 'back-1' }]) - expect(tracker.stoppableTaskIds).toEqual(['back-1']) - }) - - it('keeps live foreground work across an aggregate roster that never lists it', () => { - const tracker = new ClaudeBackgroundTaskTracker() - tracker.observe({ type: 'user' }, true) - tracker.observe(started('fore-1', false)) - tracker.observe( - aggregate([{ task_id: 'back-1', task_type: 'local_bash', description: 'bash' }]) - ) - - expect(rows(tracker)).toEqual([{ id: 'fore-1', stoppable: false }, { id: 'back-1' }]) - - tracker.observe(started('fore-2', false)) - expect(rows(tracker)).toEqual([ - { id: 'fore-1', stoppable: false }, - { id: 'back-1' }, - { id: 'fore-2', stoppable: false } - ]) - - tracker.observe(result()) - expect(rows(tracker)).toEqual([{ id: 'back-1' }]) - }) - - it('drops a stale backgrounded start and retains every live foreground row', () => { - const tracker = new ClaudeBackgroundTaskTracker() - tracker.observe({ type: 'user' }, true) - for (let index = 0; index < 300; index += 1) { - tracker.observe(started(`fore-${index}`, false)) - } - tracker.observe( - aggregate([{ task_id: 'back-1', task_type: 'local_bash', description: 'bash' }]) - ) - - const ids = rows(tracker).map((row) => row.id) - expect(ids).toHaveLength(301) - expect(ids).toContain('fore-0') - expect(ids).toContain('back-1') - - tracker.observe(started('stale', true)) - expect(tracker.stoppableTaskIds).toEqual(['back-1']) - }) - - it('keeps a finished foreground id dead across a roster that never listed it', () => { - const tracker = new ClaudeBackgroundTaskTracker() - tracker.observe({ type: 'user' }, true) - tracker.observe(started('fore-1', false)) - tracker.observe(system('task_notification', { task_id: 'fore-1', status: 'completed' })) - expect(tracker.state).toBeNull() - - tracker.observe( - aggregate([{ task_id: 'back-1', task_type: 'local_bash', description: 'bash' }]) - ) - tracker.observe(started('fore-1', false)) - - expect(rows(tracker)).toEqual([{ id: 'back-1' }]) - }) - - it('retires a phantom foreground row when the next turn starts', () => { - const tracker = new ClaudeBackgroundTaskTracker() - tracker.observe(started('phantom', false)) - expect(rows(tracker)).toEqual([{ id: 'phantom', stoppable: false }]) - - tracker.observe({ type: 'user' }, true) - expect(tracker.state).toBeNull() - }) - - it('settles a previous turn the way the subagent roster settles it', () => { - const tracker = new ClaudeBackgroundTaskTracker() - tracker.observe({ type: 'user' }, true) - tracker.observe(started('fore', false)) - tracker.observe(started('back', true)) - - tracker.observe({ type: 'user' }, true) - expect(rows(tracker)).toEqual([{ id: 'back' }]) - }) - - it('empties only between one task retiring and the next starting', () => { - const tracker = new ClaudeBackgroundTaskTracker() - tracker.observe({ type: 'user' }, true) - tracker.observe(started('A', false)) - expect(rows(tracker)).toEqual([{ id: 'A', stoppable: false }]) - tracker.observe(system('task_notification', { task_id: 'A', status: 'completed' })) - expect(tracker.state).toBeNull() - tracker.observe(started('B', false)) - expect(rows(tracker)).toEqual([{ id: 'B', stoppable: false }]) - - const spanned = new ClaudeBackgroundTaskTracker() - spanned.observe({ type: 'user' }, true) - spanned.observe(started('bg', true)) - spanned.observe(started('A', false)) - spanned.observe(system('task_notification', { task_id: 'A', status: 'completed' })) - expect(rows(spanned)).toEqual([{ id: 'bg' }]) - }) -}) diff --git a/src/main/claude/claude-background-task-turn-resumption.test.ts b/src/main/claude/claude-background-task-turn-resumption.test.ts index b84d336b225..8bf7c5355ef 100644 --- a/src/main/claude/claude-background-task-turn-resumption.test.ts +++ b/src/main/claude/claude-background-task-turn-resumption.test.ts @@ -119,113 +119,6 @@ describe('a typed background-task row opens the turn it resumes', () => { expect(projected(items())).toBe('working') }) - it('keeps an idle chat idle when a live task only reports progress', () => { - const { translator, items } = harness() - spawnToolCall(translator) - translator.handle( - systemFrame('s1', { - subtype: 'task_started', - task_id: 'live-progress', - tool_use_id: TOOL, - task_type: 'local_bash', - description: 'Wait for the verification verdict', - is_backgrounded: true - }) - ) - settleTurn(translator) - expect(projected(items())).toBe('idle') - - translator.handle( - systemFrame('s2', { - subtype: 'task_progress', - task_id: 'live-progress', - tool_use_id: TOOL, - usage: { total_tokens: 14866 } - }) - ) - - const taskRow = items().find((item) => item.itemId.includes('live-progress')) - expect(taskRow).toBeDefined() - expect(JSON.stringify(taskRow?.body)).toContain('14866') - expect( - items().filter((item) => readAgentJournalTurn(item.body)?.state === 'running') - ).toHaveLength(0) - expect(projected(items())).toBe('idle') - }) - - it('keeps an idle chat idle when a live task roster only refreshes its name', () => { - const { translator, items } = harness() - spawnToolCall(translator) - translator.handle( - systemFrame('s1', { - subtype: 'task_started', - task_id: 'live-roster', - tool_use_id: TOOL, - task_type: 'local_bash', - is_backgrounded: true - }) - ) - settleTurn(translator) - expect(projected(items())).toBe('idle') - - translator.handle( - systemFrame('s2', { - subtype: 'background_tasks_changed', - tasks: [ - { - task_id: 'live-roster', - task_type: 'local_bash', - description: 'Named after the turn settled' - } - ] - }) - ) - - const taskRow = items().find((item) => item.itemId.includes('live-roster')) - expect(taskRow).toBeDefined() - expect(JSON.stringify(taskRow?.body)).toContain('Named after the turn settled') - expect( - items().filter((item) => readAgentJournalTurn(item.body)?.state === 'running') - ).toHaveLength(0) - expect(projected(items())).toBe('idle') - }) - - it('does not reopen a completed turn for a late task revision', () => { - const { translator, items } = harness() - spawnToolCall(translator) - translator.handle( - systemFrame('s1', { - subtype: 'task_started', - task_id: 'late-revision', - tool_use_id: TOOL, - task_type: 'local_bash', - description: 'Wait for the verification verdict', - is_backgrounded: true - }) - ) - translator.handle( - systemFrame('s2', { - subtype: 'task_notification', - task_id: 'late-revision', - tool_use_id: TOOL, - status: 'completed', - summary: 'first run finished' - }) - ) - settleTurn(translator) - expect(projected(items())).toBe('idle') - - translator.handle( - systemFrame('s3', { - subtype: 'task_progress', - task_id: 'late-revision', - usage: { total_tokens: 99 } - }) - ) - - expect(projected(items())).toBe('idle') - }) - it('does not reopen a completed turn when an overflow terminal row is enriched', () => { const { translator, items } = harness() for (let index = 0; index < 64; index += 1) { diff --git a/src/main/claude/claude-child-exit-proof-ladder.test.ts b/src/main/claude/claude-child-exit-proof-ladder.test.ts deleted file mode 100644 index 8249d3eed6c..00000000000 --- a/src/main/claude/claude-child-exit-proof-ladder.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { PassThrough } from 'node:stream' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' -import { - createClaudeChildTreeReaper, - type ClaudeChildTreeReaper -} from './claude-agent-sdk-exit-proof' -import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' -import { managedChild } from './claude-child-exit-proof-fixture' -import { proveClaudeChildExitWithReaper } from './claude-child-exit-proof-ladder' - -function fakeTree(): ClaudeChildTreeReaper & { reap: ReturnType } { - return { - capture: vi.fn(async () => {}), - refresh: vi.fn(async () => {}), - reap: vi.fn(async () => 'exited' as const), - treeVerdict: 'exited', - forcedReapAttempted: false - } -} - -function rootStoppedBySigterm(stopMs: number, platform: NodeJS.Platform) { - const child = Object.assign(createFakePipedChild(), { - pid: 4321, - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn((signal?: NodeJS.Signals | number) => { - if (signal === 'SIGTERM') { - setTimeout(() => child.emit('exit', 0, 'SIGTERM'), stopMs) - } - return true - }) - }) - const spawner = createClaudeCodeProcessSpawn(() => { - return child - }, platform) - spawner.spawn({ - command: 'fixture-provider', - args: [], - env: {}, - signal: new AbortController().signal - }) - const managed = spawner.managed - if (!managed) { - throw new Error('Fixture did not retain its managed child') - } - return { child, managed } -} - -function fixtureRoot( - closePlatform: NodeJS.Platform, - leavesOnStdinEnd: boolean, - leavesOnKill = true -) { - const child = Object.assign(createFakePipedChild(), { - pid: 4321, - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn(() => { - if (leavesOnKill) { - child.emit('exit', null, 'SIGKILL') - } - return true - }) - }) - if (leavesOnStdinEnd) { - child.stdin.on('finish', () => child.emit('exit', 0, null)) - } - return { child, managed: managedChild(child, closePlatform) } -} - -function windowsTree( - root: ReturnType, - terminateWindowsTree: (rootPid: number) => Promise -) { - const captureDescendants = vi.fn(async () => null) - const tree = createClaudeChildTreeReaper(root.child, { - platform: 'win32', - exited: () => root.managed.rootVerdict === 'exited', - captureDescendants, - terminateWindowsTree - }) - return { tree, reap: vi.spyOn(tree, 'reap'), captureDescendants } -} - -afterEach(() => vi.useRealTimers()) - -describe('Claude child exit proof ladder', () => { - it('stops a supervised child with SIGTERM and waits out the supervisor stop before forcing', async () => { - vi.useFakeTimers() - const root = rootStoppedBySigterm(PROVIDER_SUPERVISOR_MAX_STOP_MS - 500, 'darwin') - const tree = fakeTree() - expect(root.managed.rootVerdict).toBe('live') - const proof = proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) - await vi.advanceTimersByTimeAsync(PROVIDER_SUPERVISOR_MAX_STOP_MS) - await expect(proof).resolves.toBe(true) - expect(root.child.stdin.writableEnded).toBe(true) - expect(root.child.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM') - expect(root.managed.lastCloseResult).toEqual({ root: 'exited', tree: 'exited' }) - expect(tree.reap).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - }) - - it('never signals an unsupervised child for the graceful stop', async () => { - vi.useFakeTimers() - const root = rootStoppedBySigterm(0, 'win32') - const tree = fakeTree() - const proof = proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) - await vi.advanceTimersByTimeAsync(2_500) - await expect(proof).resolves.toBe(false) - expect(root.child.stdin.writableEnded).toBe(true) - expect(root.child.kill).not.toHaveBeenCalledWith('SIGTERM') - expect(tree.reap).toHaveBeenCalledOnce() - expect(root.managed.lastCloseResult).toEqual({ root: 'live', tree: 'exited' }) - expect(vi.getTimerCount()).toBe(0) - }) - - it('on Windows proves a close when Claude leaves on its own after its stdin ends', async () => { - const root = fixtureRoot('win32', true) - const terminateWindowsTree = vi.fn(async () => true) - const { tree, reap, captureDescendants } = windowsTree(root, terminateWindowsTree) - - await expect( - proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) - ).resolves.toBe(true) - - // Voluntary Windows exit makes no claim about Claude's descendants. - expect(reap).not.toHaveBeenCalled() - expect(terminateWindowsTree).not.toHaveBeenCalled() - expect(captureDescendants).not.toHaveBeenCalled() - expect(root.child.kill).not.toHaveBeenCalled() - expect(root.managed.lastCloseResult).toEqual({ - root: 'exited', - tree: 'unverifiable', - selfExit: true - }) - }) - - it.each([ - { taskkill: true, proven: true }, - { taskkill: false, proven: false } - ])( - 'on Windows a forced close is proven only by taskkill: taskkill $taskkill', - async ({ taskkill, proven }) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const root = fixtureRoot('win32', false) - const terminateWindowsTree = vi.fn(async () => taskkill) - const { tree } = windowsTree(root, terminateWindowsTree) - - let settled = false - const proof = proveClaudeChildExitWithReaper( - { managed: root.managed, tree }, - () => tree - ).finally(() => (settled = true)) - await new Promise((resolve) => setImmediate(resolve)) - expect(root.child.stdin.writableEnded).toBe(true) - await vi.advanceTimersByTimeAsync(1_499) - expect(settled).toBe(false) - expect(terminateWindowsTree).not.toHaveBeenCalled() - expect(root.managed.rootVerdict).toBe('live') - await vi.advanceTimersByTimeAsync(1) - await expect(proof).resolves.toBe(proven) - - expect(terminateWindowsTree).toHaveBeenCalledWith(4321) - expect(root.managed.rootVerdict).toBe('exited') - }, - 10_000 - ) - - it('on POSIX still reaps a root that left on its own and keeps the tree verdict', async () => { - const root = fixtureRoot('linux', true) - const tree = { ...fakeTree(), treeVerdict: 'unverifiable' as const } - - await expect( - proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) - ).resolves.toBe(false) - expect(tree.reap).toHaveBeenCalledOnce() - }) - - it('on Windows a retried close after a failed taskkill stays unproven once the root exits', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const root = fixtureRoot('win32', false, false) - const terminateWindowsTree = vi.fn(async () => false) - const { tree } = windowsTree(root, terminateWindowsTree) - const close = () => proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) - - let settled = false - const proof = close().finally(() => (settled = true)) - await new Promise((resolve) => setImmediate(resolve)) - expect(root.child.stdin.writableEnded).toBe(true) - await vi.advanceTimersByTimeAsync(1_499) - expect(settled).toBe(false) - expect(terminateWindowsTree).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(terminateWindowsTree).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(999) - expect(settled).toBe(false) - expect(root.managed.rootVerdict).toBe('live') - await vi.advanceTimersByTimeAsync(1) - await expect(proof).resolves.toBe(false) - // Exit after a forced reap must not count as voluntary. - root.child.emit('exit', null, 'SIGKILL') - await expect(close()).resolves.toBe(false) - - expect(terminateWindowsTree).toHaveBeenCalledOnce() - expect(tree.forcedReapAttempted).toBe(true) - expect(tree.treeVerdict).toBe('unverifiable') - }, 10_000) - - it.each([ - { taskkill: true, proven: true }, - { taskkill: false, proven: false } - ])( - 'on Windows a reap outside a close keeps taskkill as the verdict: taskkill $taskkill', - async ({ taskkill, proven }) => { - const root = fixtureRoot('win32', false) - const terminateWindowsTree = vi.fn(async () => taskkill) - const { tree } = windowsTree(root, terminateWindowsTree) - await tree.reap() - expect(root.managed.rootVerdict).toBe('exited') - - await expect( - proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) - ).resolves.toBe(proven) - expect(terminateWindowsTree).toHaveBeenCalledOnce() - } - ) -}) diff --git a/src/main/claude/claude-child-work-decoder.test.ts b/src/main/claude/claude-child-work-decoder.test.ts index 0aaf043146b..59b077da25b 100644 --- a/src/main/claude/claude-child-work-decoder.test.ts +++ b/src/main/claude/claude-child-work-decoder.test.ts @@ -4,15 +4,6 @@ import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' function system(subtype: string, fields: Record): Record { return { type: 'system', subtype, session_id: 'provider-1', uuid: crypto.randomUUID(), ...fields } } - -const foregroundAgent = system('task_started', { - task_id: 'agent-fg', - tool_use_id: 'toolu_fg', - task_type: 'local_agent', - subagent_type: 'Explore', - description: 'Find flaky tests', - is_backgrounded: false -}) const backgroundAgent = system('task_started', { task_id: 'agent-bg', tool_use_id: 'toolu_bg', @@ -31,200 +22,6 @@ function decoderWith(...messages: Record[]): ClaudeChildWorkDec } describe('Claude child-work decoder', () => { - it('names a started child by its task id and the spawn call of this run', () => { - const decoder = new ClaudeChildWorkDecoder() - decoder.observe(foregroundAgent) - expect(decoder.drain(500)).toEqual([ - { - type: 'live', - observedAt: 500, - child: { - handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg' }, - kind: 'agent', - residency: 'foreground', - state: 'working', - name: 'Explore', - agentType: 'Explore', - description: 'Find flaky tests', - stoppable: false - } - } - ]) - expect(decoder.drain(600)).toEqual([]) - }) - - it('marks a background orca orchestration check --wait as the worker wait it is', () => { - const decoder = new ClaudeChildWorkDecoder() - decoder.observe({ - type: 'assistant', - session_id: 'provider-1', - uuid: 'assistant-1', - parent_tool_use_id: null, - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'toolu_wait', - name: 'Bash', - input: { - command: `ORCA_RUN=${'x'.repeat(200)} orca orchestration check --wait --json`, - run_in_background: true - } - } - ] - } - }) - decoder.observe( - system('task_started', { - task_id: 'wait-1', - tool_use_id: 'toolu_wait', - task_type: 'local_bash', - description: 'Wait for next worker message', - is_backgrounded: true - }) - ) - expect(decoder.drain(500)).toMatchObject([ - { - type: 'live', - child: { - kind: 'command', - operation: { toolName: 'Bash', input: 'orca orchestration check --wait' } - } - } - ]) - }) - - it("carries a child's progress: the tool it last ran, its summary and its usage", () => { - const decoder = decoderWith(foregroundAgent) - decoder.observe( - system('task_progress', { - task_id: 'agent-fg', - description: 'Running Bash', - last_tool_name: 'Bash', - summary: 'Reproducing the flake', - usage: { total_tokens: 1_200, tool_uses: 3, duration_ms: 900 } - }) - ) - expect(decoder.drain(600)).toEqual([ - expect.objectContaining({ - type: 'live', - observedAt: 600, - child: expect.objectContaining({ - handle: { idKind: 'task_id', id: 'agent-fg', runId: 'toolu_fg' }, - description: 'Find flaky tests', - operation: { toolName: 'Bash', basis: 'reported', observedAt: 600 }, - lastMessage: 'Reproducing the flake', - totalTokens: 1_200 - }) - }) - ]) - }) - - it('reports how a child ended in the outcome vocabulary, naming the run that ended', () => { - const decoder = decoderWith(backgroundAgent) - decoder.observe( - system('task_notification', { - task_id: 'agent-bg', - tool_use_id: 'toolu_bg', - status: 'failed', - summary: 'Build broke', - usage: { total_tokens: 900 } - }) - ) - expect(decoder.drain(500)).toEqual([ - { - type: 'ended', - observedAt: 500, - handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_bg' }, - outcome: 'failed', - lastMessage: 'Build broke', - totalTokens: 900 - } - ]) - for (const [status, outcome] of [ - ['completed', 'succeeded'], - ['killed', 'cancelled'], - ['stopped', 'cancelled'], - ['whatever', 'unknown'] - ]) { - decoder.observe(system('task_notification', { task_id: 'agent-bg', status })) - expect(decoder.drain(500)).toEqual([expect.objectContaining({ type: 'ended', outcome })]) - } - decoder.observe( - system('task_updated', { task_id: 'agent-bg', patch: { status: 'failed', error: 'OOM' } }) - ) - expect(decoder.drain(500)).toEqual([ - expect.objectContaining({ type: 'ended', outcome: 'failed', lastMessage: 'OOM' }) - ]) - }) - - it('ends a background agent at the notification that wakes its launcher, not the update before it', () => { - for (const status of ['completed', 'failed', 'killed']) { - const decoder = decoderWith(backgroundAgent) - decoder.observe(system('task_updated', { task_id: 'agent-bg', patch: { status } })) - expect(decoder.drain(500)).toEqual([]) - decoder.observe(system('task_notification', { task_id: 'agent-bg', status })) - expect(decoder.drain(600)).toEqual([expect.objectContaining({ type: 'ended' })]) - } - }) - - it("reads nothing from a roster, a turn's end or a spawn call's result", () => { - const decoder = decoderWith(foregroundAgent, backgroundAgent) - decoder.observe(system('background_tasks_changed', { tasks: [] })) - decoder.observe({ type: 'result', subtype: 'success' }) - decoder.observe({ - type: 'user', - parent_tool_use_id: null, - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: 'toolu_fg', content: 'done', is_error: true }] - } - }) - expect(decoder.drain(500)).toEqual([]) - }) - - it('moves a child to the background when the provider says it moved', () => { - const decoder = decoderWith(foregroundAgent) - decoder.observe( - system('task_updated', { task_id: 'agent-fg', patch: { is_backgrounded: true } }) - ) - expect(decoder.drain(500)).toEqual([ - expect.objectContaining({ - child: expect.objectContaining({ residency: 'background', stoppable: true }) - }) - ]) - }) - - it('reports the run a completion ended waking under the same spawn call as that run again', () => { - const decoder = decoderWith( - backgroundAgent, - system('task_notification', { - task_id: 'agent-bg', - tool_use_id: 'toolu_bg', - status: 'completed' - }) - ) - for (let index = 0; index < 300; index += 1) { - const id = `other-${index}` - decoder.observe({ ...backgroundAgent, task_id: id, tool_use_id: `toolu_${index}` }) - decoder.observe(system('task_notification', { task_id: id, status: 'completed' })) - } - decoder.drain(400) - decoder.observe(backgroundAgent) - decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' })) - expect(decoder.drain(500)).toEqual([ - expect.objectContaining({ - type: 'live', - restart: true, - child: expect.objectContaining({ - handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_bg' } - }) - }), - expect.objectContaining({ type: 'live' }) - ]) - }) - it('reports a start for an ended child as a restart, and a late start of a stopped run as nothing', () => { const decoder = decoderWith( backgroundAgent, @@ -257,72 +54,4 @@ describe('Claude child-work decoder', () => { }) ]) }) - - it('never records work the CLI hides from its transcript', () => { - const decoder = new ClaudeChildWorkDecoder() - decoder.observe({ ...backgroundAgent, ambient: true }) - decoder.observe({ ...foregroundAgent, skip_transcript: true }) - expect(decoder.drain(500)).toEqual([]) - }) - - it('admits every live child', () => { - const decoder = new ClaudeChildWorkDecoder() - for (let index = 0; index < 300; index += 1) { - decoder.observe({ ...backgroundAgent, task_id: `agent-${index}` }) - } - expect(decoder.drain(500)).toHaveLength(300) - }) - - it('marks the end of the provider session', () => { - const decoder = decoderWith(backgroundAgent) - decoder.clear() - expect(decoder.drain(500)).toEqual([{ type: 'session-ended', observedAt: 500 }]) - decoder.observe(backgroundAgent) - expect(decoder.drain(600)).toEqual([expect.not.objectContaining({ restart: true })]) - }) -}) - -describe('Claude children waiting on a permission request', () => { - const states = (edges: ReturnType) => - edges.flatMap((edge) => - edge.type === 'live' ? [`${edge.child.handle.id} ${edge.child.state}`] : [edge.type] - ) - - it('reports a live child that starts or stops waiting, and only then', () => { - const decoder = decoderWith(backgroundAgent) - decoder.observeWaiting(new Set(['agent-bg', 'agent-untracked'])) - expect(states(decoder.drain(500))).toEqual(['agent-bg waiting']) - decoder.observeWaiting(new Set(['agent-bg', 'agent-untracked'])) - expect(decoder.drain(600)).toEqual([]) - decoder.observeWaiting(new Set()) - expect(states(decoder.drain(700))).toEqual(['agent-bg working']) - }) - - it('reads every live report of a waiting child as waiting, a start included', () => { - const decoder = new ClaudeChildWorkDecoder() - decoder.observeWaiting(new Set(['agent-bg'])) - decoder.observe(backgroundAgent) - decoder.observe(foregroundAgent) - decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Bash' })) - expect(states(decoder.drain(500))).toEqual([ - 'agent-bg waiting', - 'agent-fg working', - 'agent-bg waiting' - ]) - }) - - it('settles a waiting child on its own ending, and forgets every wait with the session', () => { - const decoder = decoderWith(backgroundAgent) - decoder.observeWaiting(new Set(['agent-bg'])) - decoder.observe(system('task_notification', { task_id: 'agent-bg', status: 'stopped' })) - expect(states(decoder.drain(500))).toEqual(['agent-bg waiting', 'ended']) - decoder.observeWaiting(new Set()) - expect(decoder.drain(600)).toEqual([]) - decoder.observe(system('task_started', { ...backgroundAgent, tool_use_id: 'toolu_bg_2' })) - decoder.observeWaiting(new Set(['agent-bg'])) - decoder.clear() - const edges = states(decoder.drain(700)) - expect(edges.at(-1)).toBe('session-ended') - expect(edges).not.toContain('agent-bg waiting') - }) }) diff --git a/src/main/claude/claude-cli-flag-support.test.ts b/src/main/claude/claude-cli-flag-support.test.ts deleted file mode 100644 index 2955c30b13c..00000000000 --- a/src/main/claude/claude-cli-flag-support.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - CLAUDE_PLUGIN_DIR_FLAG, - CLAUDE_THINKING_DISPLAY_FLAG, - createClaudeCliFlagSupport -} from './claude-cli-flag-support' - -const LAUNCH = { command: '/bin/claude', cwd: '/repo', env: { PATH: '/shims' } } -const UNKNOWN_FLAG = new Error( - "claude stream-json exited (code 1): error: unknown option '--thinking-display'" -) - -type Support = ReturnType - -const supports = async ( - support: Support, - ...args: Parameters -): Promise => (await support.check(...args)) === 'supported' - -const thinking = (support: Support, launch: typeof LAUNCH): Promise => - supports(support, CLAUDE_THINKING_DISPLAY_FLAG, launch) - -type Probe = ( - command: string, - launch: { - cwd: string - env: Record - timeoutMs: number - prefixArgs?: readonly string[] - } -) => Promise - -function supportWith( - probe: Probe, - budgetMs = 20, - keyOf = async (command: string, cwd: string): Promise => `${command}\n${cwd}` -) { - const calls = vi.fn(probe) - let skippedMs = 0 - const support = createClaudeCliFlagSupport({ - probe: calls, - keyOf, - budgetMs, - now: () => performance.now() + skippedMs - }) - return { support, calls, skip: (ms: number) => (skippedMs += ms) } -} - -function heldProbe() { - let answer: (version: string | null) => void = () => {} - const probe: Probe = () => - new Promise((resolve) => { - answer = resolve - }) - return { probe, answer: (version: string | null) => answer(version) } -} - -describe('whether a launch passes a Claude CLI flag', () => { - it("probes with the launch's own cwd and env and its own kill timeout, then remembers", async () => { - const { support, calls } = supportWith(async () => '2.1.280') - await expect(thinking(support, LAUNCH)).resolves.toBe(true) - await expect(thinking(support, LAUNCH)).resolves.toBe(true) - expect(calls).toHaveBeenCalledTimes(1) - expect(calls).toHaveBeenCalledWith('/bin/claude', { - cwd: '/repo', - prefixArgs: undefined, - env: { PATH: '/shims' }, - timeoutMs: 10_000 - }) - }) - - it('asks again per workspace: a shim can pick a different CLI there', async () => { - const { support, calls } = supportWith(async () => '2.1.280') - await thinking(support, LAUNCH) - await thinking(support, { ...LAUNCH, cwd: '/other' }) - expect(calls).toHaveBeenCalledTimes(2) - }) - - it('passes nothing to a CLI older than the flag, and keeps that answer for good', async () => { - const { support, calls, skip } = supportWith(async () => '2.1.92') - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - skip(60 * 60_000) - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - expect(calls).toHaveBeenCalledTimes(1) - }) - - it('never remembers a probe that printed no version, failed or was killed: the next launch asks', async () => { - for (const failure of [async () => null, () => Promise.reject(new Error('EMFILE'))]) { - let answer: () => Promise = failure - const { support, calls } = supportWith(() => answer()) - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - answer = async () => '2.1.280' - await expect(thinking(support, LAUNCH)).resolves.toBe(true) - expect(calls).toHaveBeenCalledTimes(2) - } - }) - - it('waits as long as the caller allows for a probe slower than the default budget', async () => { - const { support, calls } = supportWith( - () => new Promise((resolve) => setTimeout(() => resolve('2.1.280'), 60)), - 20 - ) - const [plugins, thinkingDisplay] = await Promise.all([ - supports(support, CLAUDE_PLUGIN_DIR_FLAG, LAUNCH, 1_000), - thinking(support, LAUNCH) - ]) - expect(plugins).toBe(true) - expect(thinkingDisplay).toBe(false) - expect(calls).toHaveBeenCalledTimes(1) - await expect(thinking(support, LAUNCH)).resolves.toBe(true) - }) - - it('bounds even a long budget: a probe that never answers costs at most that budget', async () => { - const held = heldProbe() - const { support } = supportWith(held.probe, 20) - const started = performance.now() - await expect(supports(support, CLAUDE_PLUGIN_DIR_FLAG, LAUNCH, 80)).resolves.toBe(false) - expect(performance.now() - started).toBeLessThan(500) - }) - - it('waits for a probe that answers within the budget', async () => { - const { support } = supportWith( - () => new Promise((resolve) => setTimeout(() => resolve('2.1.280'), 5)), - 1_000 - ) - await expect(thinking(support, LAUNCH)).resolves.toBe(true) - }) - - it('does not wait again on a probe already past its budget, and keeps its late answer', async () => { - const held = heldProbe() - const { support, calls } = supportWith(held.probe, 30) - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - const started = performance.now() - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - expect(performance.now() - started).toBeLessThan(25) - held.answer('2.1.280') - await vi.waitFor(async () => expect(await thinking(support, LAUNCH)).toBe(true)) - expect(calls).toHaveBeenCalledTimes(1) - }) - - it('stops passing the flag to a binary that exited refusing it', async () => { - const { support, calls } = supportWith(async () => '2.1.280') - await thinking(support, LAUNCH) - support.observeExit(LAUNCH, UNKNOWN_FLAG) - await vi.waitFor(async () => expect(await thinking(support, LAUNCH)).toBe(false)) - expect(calls).toHaveBeenCalledTimes(1) - }) - - it('keeps a refusal seen while a probe was still running', async () => { - const held = heldProbe() - const { support } = supportWith(held.probe, 10) - await thinking(support, LAUNCH) - support.observeExit(LAUNCH, UNKNOWN_FLAG) - await new Promise((resolve) => setTimeout(resolve, 0)) - held.answer('2.1.280') - await new Promise((resolve) => setTimeout(resolve, 0)) - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - }) - - it("keeps the flag through another failure or another option's refusal", async () => { - const { support } = supportWith(async () => '2.1.280') - await thinking(support, LAUNCH) - support.observeExit(LAUNCH, new Error('claude stream-json exited (code 1): not signed in')) - support.observeExit(LAUNCH, new Error("error: unknown option '--thinking'")) - await new Promise((resolve) => setTimeout(resolve, 0)) - await expect(thinking(support, LAUNCH)).resolves.toBe(true) - }) - - it('spends the budget finding the binary too, and caches nothing when that runs out', async () => { - let slow = true - let slowKeyTimer: ReturnType | undefined - const { support, calls } = supportWith( - async () => '2.1.280', - 30, - async (command, cwd) => { - if (slow) { - await new Promise((resolve) => { - slowKeyTimer = setTimeout(resolve, 1_000) - }) - } - return `${command}\n${cwd}` - } - ) - try { - const started = performance.now() - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - expect(performance.now() - started).toBeLessThan(500) - expect(calls).not.toHaveBeenCalled() - slow = false - await expect(thinking(support, LAUNCH)).resolves.toBe(true) - } finally { - clearTimeout(slowKeyTimer) - } - }) - - it('forgets the binaries launched least recently, not the ones in use', async () => { - const { support, calls } = supportWith(async () => '2.1.280') - const at = (index: number) => ({ ...LAUNCH, cwd: `/repo-${index}` }) - for (let index = 1; index <= 32; index += 1) { - await thinking(support, at(index)) - } - await thinking(support, at(1)) - await thinking(support, at(33)) - expect(calls).toHaveBeenCalledTimes(33) - await thinking(support, at(1)) - expect(calls).toHaveBeenCalledTimes(33) - await thinking(support, at(2)) - expect(calls).toHaveBeenCalledTimes(34) - }) - - it('answers every flag from one probe, each against its own first version', async () => { - const { support, calls } = supportWith(async () => '2.1.0') - await expect(supports(support, CLAUDE_PLUGIN_DIR_FLAG, LAUNCH)).resolves.toBe(true) - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - expect(calls).toHaveBeenCalledTimes(1) - }) - - it('shares a probe still running between flags', async () => { - const held = heldProbe() - const { support, calls } = supportWith(held.probe, 1_000) - const both = Promise.all([ - thinking(support, LAUNCH), - supports(support, CLAUDE_PLUGIN_DIR_FLAG, LAUNCH) - ]) - await vi.waitFor(() => expect(calls).toHaveBeenCalled()) - held.answer('2.1.280') - await expect(both).resolves.toEqual([true, true]) - expect(calls).toHaveBeenCalledTimes(1) - }) - - it('withdraws only the flag a binary refused', async () => { - const { support } = supportWith(async () => '2.1.280') - await thinking(support, LAUNCH) - support.observeExit(LAUNCH, new Error("error: unknown option '--plugin-dir'")) - await vi.waitFor(async () => - expect(await supports(support, CLAUDE_PLUGIN_DIR_FLAG, LAUNCH)).toBe(false) - ) - await expect(thinking(support, LAUNCH)).resolves.toBe(true) - }) - - it('keeps a refusal through a failed probe and a later one that answers', async () => { - let version: string | null = null - const { support } = supportWith(async () => version) - await thinking(support, LAUNCH) - support.observeExit(LAUNCH, UNKNOWN_FLAG) - await new Promise((resolve) => setTimeout(resolve, 0)) - version = '2.1.280' - await expect(supports(support, CLAUDE_PLUGIN_DIR_FLAG, LAUNCH)).resolves.toBe(true) - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - }) - - it('prewarms without waiting, so a later launch finds the answer and spawns nothing', async () => { - const held = heldProbe() - const { support, calls } = supportWith(held.probe, 20) - support.prewarm(LAUNCH) - await vi.waitFor(() => expect(calls).toHaveBeenCalledOnce()) - held.answer('2.1.280') - await expect(supports(support, CLAUDE_PLUGIN_DIR_FLAG, LAUNCH)).resolves.toBe(true) - support.prewarm(LAUNCH) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(calls).toHaveBeenCalledOnce() - }) - - it('a prewarm that finds no binary probes nothing and throws nothing', async () => { - const { support, calls } = supportWith( - async () => '2.1.280', - 20, - async () => { - throw new Error('EACCES') - } - ) - support.prewarm(LAUNCH) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(calls).not.toHaveBeenCalled() - }) - - it('answers from what is known without starting a probe when asked not to', async () => { - const { support, calls } = supportWith(async () => '2.1.280') - await expect(supports(support, CLAUDE_THINKING_DISPLAY_FLAG, LAUNCH, 250, false)).resolves.toBe( - false - ) - expect(calls).not.toHaveBeenCalled() - await supports(support, CLAUDE_PLUGIN_DIR_FLAG, LAUNCH) - await expect(supports(support, CLAUDE_THINKING_DISPLAY_FLAG, LAUNCH, 250, false)).resolves.toBe( - true - ) - expect(calls).toHaveBeenCalledOnce() - }) -}) - -it('keeps version facts separate for different prefixes through the same program', async () => { - const { support, calls } = supportWith(async (_command, launch) => - launch.prefixArgs?.includes('new') ? '2.1.280' : '2.0.0' - ) - await expect( - supports(support, CLAUDE_THINKING_DISPLAY_FLAG, { ...LAUNCH, prefixArgs: ['old'] }) - ).resolves.toBe(false) - await expect( - supports(support, CLAUDE_THINKING_DISPLAY_FLAG, { ...LAUNCH, prefixArgs: ['new'] }) - ).resolves.toBe(true) - expect(calls).toHaveBeenCalledTimes(2) -}) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index ff9993301cd..f07f910e12e 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -6,13 +6,8 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session/journal/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session/lifecycle/agent-session-turn-record' import { DISPATCH_REJECTED_CANCELLED } from '../../shared/agent-session/requests/structured-agent-session-dispatch-rejection' -import { - DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, - DISPATCH_DOUBT_PROVIDER_IDLE -} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' -import { agentSessionFailureFact } from '../../shared/agent-session/failures/agent-session-failure' +import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' -import { claudeDispatchRejection } from './structured/claude-structured-dispatch-content' import { ClaudeStructuredSessionAdapter } from './structured/claude-structured-session-adapter' import type { ClaudeLateDispatchOutcome } from './structured/claude-structured-session-state' import { @@ -280,77 +275,9 @@ describe('a Stop whose interrupt cancelled a queued follow-up (cancel_queued)', expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) } ) - - it('ignores a cancelled frame for a send it no longer holds, or never sent', async () => { - const replay = await replayCapture('interrupt-lost', { - atControl: stopWithInterruptAnswer('lost') - }) - for (const commandUuid of [ - replay.liveUuid('client-A'), - replay.liveUuid('client-B'), - '5a1b0c63-0000-4000-8000-000000000000' - ]) { - replay.connection.handlers.onMessage?.({ - type: 'command_lifecycle', - command_uuid: commandUuid, - state: 'cancelled', - uuid: `lifecycle-${commandUuid}`, - session_id: PROVIDER_SESSION_ID - }) - } - - expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) - expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) - }) -}) - -describe('a Stop that withdraws the follow-up one at a time (no cancel_queued)', () => { - it.each([ - [ - 'times out', - () => { - throw new ClaudeControlRequestError('cancel_async_message', 'Control request timed out') - } - ], - [ - 'errors', - () => { - throw new Error('Query closed before response received') - } - ], - ['answers false', () => false] - ] as const)( - 'withdraws it from the CLI’s cancelled frame when cancel_async_message %s', - async (_label, answer) => { - const replay = await replayCapture('cancel-async', { - withoutCapabilities: ['interrupt_cancel_queued_v1'], - stopAfterControl: true, - atControl: async (point) => { - const queued = point.liveUuid('client-B') - point.routes.interrupt = () => ({ still_queued: [queued] }) - point.routes.cancel_async_message = () => { - point.deliverInFlight() - return answer() - } - await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) - } - }) - - expect(replay.connection.calls.map((call) => call.subtype)).toContain('cancel_async_message') - expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) - expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) - } - ) }) describe('a queued send the CLI withdraws without Orca hearing why', () => { - it('settles the withdrawn send from its own cancelled frame', async () => { - const replay = await replayCapture('cancel-async') - - expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) - expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) - }) - it('withdraws only the batch lead; the send behind it runs as its own turn and is accepted', async () => { const replay = await replayCapture('batch-lead') @@ -372,34 +299,9 @@ function isLifecycleFrame(frame: Record, state?: string): boole return frame.type === 'command_lifecycle' && (state === undefined || frame.state === state) } -function isIdleFrame(frame: Record): boolean { - return frame.subtype === 'session_state_changed' && frame.state === 'idle' -} - -function capturedUuid(name: string, clientMessageId: string): string { - const dispatch = loadCapture(name).find( - (event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId - ) - return dispatch?.kind === 'dispatch' ? dispatch.sentUuid : '' -} - const ENDED_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } -const IDLE_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_IDLE } describe('a send the CLI started, then cancelled', () => { - it('stays accepted when its turn failed after the echo', async () => { - const replay = await replayCapture('auth-failed') - - expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) - }) - - it('is released as doubt, never withdrawn, when the cancelled frame came before any echo', async () => { - const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) - - expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) - expect(replay.adapter.holdsDispatch('session-1')).toBe(false) - }) - it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput, @@ -424,128 +326,10 @@ describe('a send the CLI started, then cancelled', () => { expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT, acceptedAs(uuid)]) }) - - it.each(['discarded', 'refused'])( - 'is released as doubt when its turn ends %s after it started', - async (state) => { - const replay = await replayCapture('auth-failed', { - omitFrame: isTurnOutput, - rewriteFrame: (frame) => - isLifecycleFrame(frame, 'cancelled') ? { ...frame, state } : frame - }) - - expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) - } - ) -}) - -// batch-lead client-C starts after A; interrupt-lost client-B covers a Stop while still queued. -describe('a steer a Stop interrupts after it started', () => { - it('is released as doubt, never withdrawn, when the Stop lands before its echo', async () => { - const steer = capturedUuid('batch-lead', 'client-C') - let started = false - let routes: ControlPoint['routes'] | undefined - const replay = await replayCapture('batch-lead', { - atControl: async (point) => { - routes = point.routes - point.deliverInFlight() - }, - omitFrame: () => started, - afterFrame: (frame) => { - started ||= isLifecycleFrame(frame, 'started') && frame.command_uuid === steer - return [] - } - }) - const uuid = replay.liveUuid('client-C') - expect(replay.adapter.holdsDispatch('session-1')).toBe(true) - - // Omit the result: one naming the steer would accept it. - routes!.interrupt = () => ({ still_queued: [], cancelled: [] }) - await replay.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) - for (const frame of [ - { - type: 'command_lifecycle', - command_uuid: uuid, - state: 'cancelled', - uuid: 'steer-cancelled' - }, - { type: 'system', subtype: 'session_state_changed', state: 'idle', uuid: 'steer-idle' } - ]) { - replay.connection.handlers.onMessage?.({ ...frame, session_id: PROVIDER_SESSION_ID }) - } - - expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ - { subtype: 'interrupt', params: { cancelQueued: true } } - ]) - expect(replay.settlementsFor('client-C')).toEqual([ENDED_IN_DOUBT]) - expect(replay.adapter.holdsDispatch('session-1')).toBe(false) - }) -}) - -describe('a send the CLI ended before starting it', () => { - it.each([ - ['discarded', 'notDelivered', false], - ['refused', 'providerRejected', true] - ] as const)('settles a %s send as not sent, saying why', async (state, kind, neverQueued) => { - const followUp = capturedUuid('cancel-async', 'client-B') - const replay = await replayCapture('cancel-async', { - omitFrame: (frame) => - neverQueued && isLifecycleFrame(frame, 'queued') && frame.command_uuid === followUp, - rewriteFrame: (frame) => - isLifecycleFrame(frame, 'cancelled') && frame.command_uuid === followUp - ? { ...frame, state } - : frame - }) - - expect(replay.settlementsFor('client-B')).toEqual([ - { state: 'rejected', unrun: true, ...claudeDispatchRejection(agentSessionFailureFact(kind)) } - ]) - expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) - }) }) describe('a send the CLI holds', () => { - it('holds its child from the idle sweep until the CLI idles, then is released as doubt', async () => { - const replay = await replayCapture('auth-failed', { - omitFrame: (frame) => - isTurnOutput(frame) || isLifecycleFrame(frame, 'cancelled') || isIdleFrame(frame) - }) - expect(replay.adapter.holdsDispatch('session-1')).toBe(true) - expect(replay.settlementsFor('client-A')).toEqual([]) - - replay.connection.handlers.onMessage?.({ - type: 'system', - subtype: 'session_state_changed', - state: 'idle', - uuid: 'idle-after-throw', - session_id: PROVIDER_SESSION_ID - }) - - expect(replay.settlementsFor('client-A')).toEqual([IDLE_IN_DOUBT]) - expect(replay.adapter.holdsDispatch('session-1')).toBe(false) - }) - // CLI 2.1.280 can report idle before rereading its queue. - it('leaves a queued send the CLI idles before starting to open its turn and be accepted', async () => { - const replay = await replayCapture('auth-failed', { - afterFrame: (frame) => - isLifecycleFrame(frame, 'queued') - ? [ - { - type: 'system', - subtype: 'session_state_changed', - state: 'idle', - uuid: 'idle-before-start', - session_id: frame.session_id - } - ] - : [] - }) - const uuid = replay.liveUuid('client-A') - - expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(uuid)]) - expect(replay.turnStates.get(uuid)).toBe('completed') - }) it('holds the child for a send the CLI has only queued, and does not release it at idle', async () => { const replay = await replayCapture('auth-failed', { @@ -558,43 +342,4 @@ describe('a send the CLI holds', () => { expect(replay.adapter.holdsDispatch('session-1')).toBe(true) expect(replay.settlementsFor('client-A')).toEqual([]) }) - - it('holds a queued send until the CLI withdraws it', async () => { - const held: boolean[] = [] - const replay = await replayCapture('cancel-async', { - atControl: async (point) => { - held.push(point.adapter.holdsDispatch('session-1')) - point.deliverInFlight() - held.push(point.adapter.holdsDispatch('session-1')) - } - }) - - expect(held).toEqual([true, false]) - expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) - }) - - it('holds a queued send until it starts and is echoed', async () => { - const held: boolean[] = [] - const replay = await replayCapture('batch-lead', { - omitFrame: (frame) => - frame.type === 'result' || isLifecycleFrame(frame, 'completed') || isIdleFrame(frame), - atControl: async (point) => { - point.deliverInFlight() - held.push(point.adapter.holdsDispatch('session-1')) - } - }) - - expect(held).toEqual([true]) - expect(replay.adapter.holdsDispatch('session-1')).toBe(false) - expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))]) - }) - - it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( - 'holds nothing once the %s capture has run to its idle', - async (name) => { - const replay = await replayCapture(name) - - expect(replay.adapter.holdsDispatch('session-1')).toBe(false) - } - ) }) diff --git a/src/main/claude/claude-command-turn.test.ts b/src/main/claude/claude-command-turn.test.ts index 187230d012a..b2834b1aaf7 100644 --- a/src/main/claude/claude-command-turn.test.ts +++ b/src/main/claude/claude-command-turn.test.ts @@ -1,6 +1,4 @@ -import { readFile } from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { z } from 'zod' import { agentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session/journal/agent-session-journal-types' import type { AgentJournalItemIdentity } from '../../shared/agent-session/journal/agent-session-journal-identity' @@ -13,7 +11,6 @@ import type { import { CAPTURED_COMPACT_SESSION_ID, CAPTURED_COMPACT_STOPPED_THEN_SEND, - CAPTURED_COMPACT_SUCCEEDS, type CapturedCompactEvent } from './claude-captured-compact-frames.test-fixture' import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation' @@ -24,13 +21,7 @@ const COMMAND_IDENTITY: AgentJournalItemIdentity = { } const COMMAND_KEY = agentJournalItemKey(COMMAND_IDENTITY) const NOW = 1_800_000_000_000 -const QUOTA_RESET_S = NOW / 1000 + 3600 -const CREDITS = 'This model requires usage credits' const IN_COMMAND: AgentJournalTurnScope = { kind: 'turn', turnItemId: COMMAND_KEY } -const TERMINAL_FAILURE_KEY = agentJournalItemKey({ - provider: 'orca', - clientMessageId: `terminal-failure:claude:${COMMAND_KEY}` -}) const RUNNING = { kind: 'turn' as const, turnId: 'compact:cmd-1', @@ -51,51 +42,6 @@ afterEach(() => { type Row = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } -const rejected = (rateLimitType: string, status = 'rejected') => ({ - type: 'rate_limit_event', - rate_limit_info: { status, rateLimitType, resetsAt: QUOTA_RESET_S } -}) -const COMPACT_QUOTA = "You've hit your limit" -// Typed quota with embedded limit facts, from the provider contract; not a new live capture. -const QUOTA_ASSISTANT = { - type: 'assistant', - parent_tool_use_id: null, - api_error: 'usage_limit_reached', - error: 'rate_limit', - is_api_error_message: true, - api_error_params: { - rate_limit_info: { status: 'rejected', rateLimitType: 'five_hour', resetsAt: QUOTA_RESET_S } - }, - user_message_uuid: 'compact-input', - message: { role: 'assistant', content: [{ type: 'text', text: COMPACT_QUOTA }] } -} - -const capturedLine = z.looseObject({ frame: z.record(z.string(), z.unknown()).optional() }) - -/** The recorded signed-out assistant error and result, answering this command's input. */ -async function recordedSignedOut(): Promise[]> { - const capture = await readFile( - new URL('./__fixtures__/claude-lifecycle-capture-auth-failed.jsonl', import.meta.url), - 'utf8' - ) - return capture - .trim() - .split('\n') - .map((line) => capturedLine.parse(JSON.parse(line))) - .flatMap(({ frame }) => - frame?.error === 'authentication_failed' || (frame?.type === 'result' && frame.is_error) - ? [ - { - ...frame, - session_id: CAPTURED_COMPACT_SESSION_ID, - user_message_uuid: 'compact-input', - user_message_uuids: ['compact-input'] - } - ] - : [] - ) -} - function harness() { const rows = new Map([[COMMAND_KEY, { key: COMMAND_KEY, body: RUNNING }]]) const writes: Row[] = [] @@ -176,267 +122,6 @@ function harness() { } describe('a captured /compact as the open Claude turn', () => { - it.each([ - ['success', 'account event'], - ['error_during_execution', 'account event'], - ['success', 'assistant error'], - ['error_during_execution', 'assistant error'] - ])( - 'retains quota on a failed command with a %s envelope from its %s, before composing its result', - (subtype, source) => { - const h = harness() - h.begin('compact-input') - // Structured quota/compact sequence from the provider contract; not a new live capture. - h.frame(source === 'account event' ? rejected('five_hour') : QUOTA_ASSISTANT) - h.frame({ type: 'system', subtype: 'status', compact_result: 'failed' }) - h.frame({ - type: 'result', - subtype, - is_error: subtype !== 'success', - local_command: 'compact', - terminal_reason: null, - result: subtype === 'success' ? '' : COMPACT_QUOTA, - num_turns: 0, - user_message_uuid: 'compact-input' - }) - expect(h.commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) - expect(h.drawn()).toEqual([ - expect.objectContaining({ - key: TERMINAL_FAILURE_KEY, - turnScope: IN_COMMAND, - body: expect.objectContaining({ - kind: 'status', - tone: 'warning', - failure: expect.objectContaining({ - kind: 'usageLimitReached', - usageLimit: { window: 'session', resetsAt: QUOTA_RESET_S * 1000 } - }) - }) - }) - ]) - } - ) - - it.each(['signed out', 'model credits'])( - 'keeps an explicit %s cause over a rejection received during the command', - async (cause) => { - const h = harness() - h.begin('compact-input') - h.frame(rejected('five_hour')) - if (cause === 'signed out') { - for (const frame of await recordedSignedOut()) { - h.frame(frame) - } - } else { - // Typed credit refusal, the shape the ordinary usage tests use; not a live capture. - h.frame({ - type: 'assistant', - parent_tool_use_id: null, - api_error: 'model_requires_usage_credits', - error: 'rate_limit', - is_api_error_message: true, - user_message_uuid: 'compact-input', - message: { role: 'assistant', content: [{ type: 'text', text: CREDITS }] } - }) - h.frame({ - type: 'system', - subtype: 'status', - compact_result: 'failed', - compact_error: CREDITS - }) - h.frame({ - type: 'result', - subtype: 'error_during_execution', - is_error: true, - result: CREDITS, - terminal_reason: 'api_error', - api_error_status: 429, - user_message_uuid: 'compact-input' - }) - } - expect(h.commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) - const statuses = h.drawn().filter((row) => row.body.kind === 'status') - // Signed out reads as an ordinary send does; the credit refusal keeps Claude's own words. - expect(statuses).toEqual([ - expect.objectContaining( - cause === 'signed out' - ? { - key: TERMINAL_FAILURE_KEY, - turnScope: IN_COMMAND, - body: expect.objectContaining({ - tone: 'error', - failure: expect.objectContaining({ - kind: 'notSignedIn', - detail: { text: 'Not logged in · Please run /login', audience: 'person' } - }) - }) - } - : { - turnScope: IN_COMMAND, - body: expect.objectContaining({ text: expect.stringContaining(CREDITS) }) - } - ) - ]) - expect( - statuses.some( - (row) => row.body.kind === 'status' && row.body.failure?.kind === 'usageLimitReached' - ) - ).toBe(false) - } - ) - - it('keeps a 403 access denial in its own words, not as sign-in guidance', async () => { - const h = harness() - h.begin('compact-input') - const denied = - 'Failed to authenticate. API Error: 403 Your organization does not have access to this model.' - // The recorded signed-out pair with the CLI's 403 variant, which it also labels authentication_failed. - for (const frame of await recordedSignedOut()) { - h.frame( - frame.type === 'result' - ? { ...frame, subtype: 'success', api_error_status: 403, result: denied } - : { - ...frame, - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Failed to authenticate.' }] - } - } - ) - } - expect(h.commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) - const statuses = h.drawn().filter((row) => row.body.kind === 'status') - expect(statuses).toEqual([ - expect.objectContaining({ - turnScope: IN_COMMAND, - body: expect.objectContaining({ text: expect.stringContaining(denied) }) - }) - ]) - expect( - statuses.some((row) => row.body.kind === 'status' && row.body.failure?.kind === 'notSignedIn') - ).toBe(false) - }) - - it.each([ - 'received before the command', - 'received during an earlier command', - 'cleared during it' - ])("keeps Claude's own refusal command-owned when the only rejection was %s", (when) => { - const h = harness() - if (when === 'received before the command') { - h.frame(rejected('five_hour')) - h.begin('compact-input') - } else if (when === 'received during an earlier command') { - // The account keeps the limit, but the next attempt starts with none of its own. - h.begin('earlier-input') - h.frame(rejected('five_hour')) - h.frame({ type: 'system', subtype: 'compact_boundary' }) - h.frame({ - type: 'result', - subtype: 'success', - is_error: false, - user_message_uuid: 'earlier-input' - }) - h.begin('compact-input') - } else { - // A blanket "rejected during this command" flag would borrow the older weekly limit. - h.frame(rejected('seven_day')) - h.begin('compact-input') - h.frame(rejected('five_hour')) - h.frame(rejected('five_hour', 'allowed')) - } - h.frame({ - type: 'system', - subtype: 'status', - compact_result: 'failed', - compact_error: 'Not enough messages to compact.' - }) - h.frame({ - type: 'result', - subtype: 'success', - is_error: false, - user_message_uuid: 'compact-input' - }) - expect(h.commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) - expect(h.drawn().map((row) => row.body)).toEqual([ - expect.objectContaining({ - text: 'Compaction failed: Not enough messages to compact.', - failure: expect.objectContaining({ kind: 'compactionFailed' }) - }) - ]) - }) - - it.each(['success', 'stop', 'unconfirmed', 'allowed', 'expired', 'other-error'])( - 'does not turn %s into quota failure from an account observation', - (control) => { - const h = harness() - h.begin('compact-input') - h.frame({ - type: 'rate_limit_event', - rate_limit_info: { - status: 'rejected', - rateLimitType: 'five_hour', - resetsAt: control === 'expired' ? 1 : QUOTA_RESET_S - } - }) - if (control === 'success') { - h.frame({ type: 'system', subtype: 'compact_boundary' }) - } else if (control !== 'unconfirmed') { - h.frame({ type: 'system', subtype: 'status', compact_result: 'failed' }) - } - if (control === 'stop') { - h.translator.commandInterruptRequested(RUNNING.turnId) - } - if (control === 'allowed') { - h.frame({ - type: 'rate_limit_event', - rate_limit_info: { status: 'allowed', rateLimitType: 'five_hour' } - }) - } - h.frame({ - type: 'result', - subtype: control === 'other-error' ? 'error_during_execution' : 'success', - is_error: control === 'other-error', - user_message_uuid: 'compact-input', - ...(control === 'other-error' - ? { api_error_status: 403, terminal_reason: 'api_error' } - : {}) - }) - expect( - h - .drawn() - .some( - (row) => row.body.kind === 'status' && row.body.failure?.kind === 'usageLimitReached' - ) - ).toBe(false) - expect(h.commandTurn()?.outcome).toBe( - control === 'success' ? 'success' : control === 'stop' ? 'cancellation' : 'failure' - ) - } - ) - - it('ends a finished compaction as a success, drawing only the compaction separator', () => { - const { replay, commandTurn, drawn, translator } = harness() - replay(CAPTURED_COMPACT_SUCCEEDS) - - expect(commandTurn()).toMatchObject({ - turnId: RUNNING.turnId, - state: 'completed', - outcome: 'success', - userItemId: RUNNING.userItemId, - requestedAt: RUNNING.requestedAt, - startedAt: RUNNING.startedAt - }) - expect(drawn()).toEqual([ - { - key: agentJournalItemKey({ provider: 'orca', clientMessageId: 'command-result:cmd-1' }), - body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, - turnScope: IN_COMMAND - } - ]) - expect(translator.commandTurnId).toBeNull() - }) - it("ends a stopped compaction as the user's cancellation, then answers the next send in its own turn", () => { const { replay, commandTurn, drawn, rows } = harness() replay(CAPTURED_COMPACT_STOPPED_THEN_SEND) @@ -452,46 +137,6 @@ describe('a captured /compact as the open Claude turn', () => { expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) }) - it('reads a compaction with no boundary and no stop as a failure, with Claude’s reason', () => { - const { replay, commandTurn, drawn } = harness() - replay(CAPTURED_COMPACT_STOPPED_THEN_SEND.filter((event) => !('interrupt' in event))) - - expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) - expect( - drawn().filter( - (row) => row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === COMMAND_KEY - ) - ).toEqual([ - expect.objectContaining({ - body: { - kind: 'status', - text: 'Compaction failed: API Error: Request was aborted.', - failure: { - kind: 'compactionFailed', - detail: { text: 'API Error: Request was aborted.', audience: 'person' } - }, - tone: 'error' - } - }) - ]) - }) - - it('reads a stop that lands after the summary but before the boundary as a cancellation', () => { - const { replay, commandTurn, drawn } = harness() - replay( - CAPTURED_COMPACT_SUCCEEDS.flatMap((event): CapturedCompactEvent[] => - 'frame' in event && event.frame.subtype === 'compact_boundary' - ? [] - : 'frame' in event && event.frame.compact_result === 'success' - ? [event, { at: event.at, interrupt: true }] - : [event] - ) - ) - - expect(commandTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) - }) - it('leaves the command running at a result that names another input', () => { const { begin, frame, commandTurn, translator } = harness() begin('compact-input') @@ -509,123 +154,3 @@ describe('a captured /compact as the open Claude turn', () => { expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'success' }) }) }) - -describe('the command turn at each point the ordinary result path threads through', () => { - it('suppresses a provider reopen after a command that failed, as after any failed turn', () => { - const failed = harness() - failed.begin('compact-input') - failed.frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) - failed.frame({ - type: 'assistant', - uuid: 'stray', - parent_tool_use_id: null, - message: { id: 'msg-stray', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } - }) - expect(failed.translator.currentTurnId).toBeNull() - - const finished = harness() - finished.begin('compact-input') - finished.frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) - finished.frame({ type: 'result', subtype: 'success', is_error: false }) - finished.frame({ - type: 'assistant', - uuid: 'resumed', - parent_tool_use_id: null, - message: { id: 'msg-resumed', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } - }) - expect(finished.translator.currentTurnId).not.toBeNull() - }) - - it("records the context window the command's result reports on the command's turn", () => { - const { begin, frame, commandTurn } = harness() - begin('compact-input') - frame({ type: 'system', subtype: 'init', model: 'claude-opus-5-5[1m]', uuid: 'init' }) - frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) - frame({ - type: 'result', - subtype: 'success', - is_error: false, - modelUsage: { 'claude-opus-5-5[1m]': { contextWindow: 1_000_000 } } - }) - expect(commandTurn()).toMatchObject({ - state: 'completed', - contextUsage: { window: { tokens: 1_000_000 } } - }) - }) - - it("reports a compaction Claude refused in Claude's own words, and one with none plainly", () => { - const worded = harness() - worded.begin('compact-input') - worded.frame({ - type: 'system', - subtype: 'status', - compact_result: 'failed', - compact_error: 'Not enough messages to compact.' - }) - worded.frame({ type: 'result', subtype: 'success', is_error: false }) - expect(worded.commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) - expect(worded.drawn().map((row) => row.body)).toEqual([ - { - kind: 'status', - text: 'Compaction failed: Not enough messages to compact.', - failure: { - kind: 'compactionFailed', - detail: { text: 'Not enough messages to compact.', audience: 'person' } - }, - tone: 'error' - } - ]) - - const unworded = harness() - unworded.begin('compact-input') - unworded.frame({ type: 'system', subtype: 'status', compact_result: 'failed' }) - unworded.frame({ type: 'result', subtype: 'success', is_error: false }) - expect(unworded.drawn().map((row) => row.body)).toEqual([ - { - kind: 'status', - text: 'Compaction failed.', - failure: { kind: 'compactionFailed' }, - tone: 'error' - } - ]) - }) - - it('reports a result with no compaction and no failure as a compaction Claude never confirmed', () => { - const { begin, frame, commandTurn, drawn } = harness() - begin('compact-input') - frame({ type: 'result', subtype: 'success', is_error: false }) - expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) - expect(drawn().map((row) => row.body)).toEqual([ - { - kind: 'status', - text: 'Compaction completion is unconfirmed.', - failure: { kind: 'compactionUnconfirmed' }, - tone: 'error' - } - ]) - }) - - it('fails a command whose result ended in error', () => { - const { begin, frame, commandTurn } = harness() - begin('compact-input') - frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) - expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) - }) - - it("draws one error row, the provider's, for a command whose result is an error", () => { - const { begin, frame, drawn } = harness() - begin('compact-input') - frame({ - type: 'result', - subtype: 'success', - is_error: true, - result: 'API Error: 529 upstream overloaded' - }) - expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([ - expect.objectContaining({ - body: expect.objectContaining({ text: 'API Error: 529 upstream overloaded' }), - turnScope: IN_COMMAND - }) - ]) - }) -}) diff --git a/src/main/claude/claude-context-model-switch.test.ts b/src/main/claude/claude-context-model-switch.test.ts index 8049a33c131..9da11ba40c0 100644 --- a/src/main/claude/claude-context-model-switch.test.ts +++ b/src/main/claude/claude-context-model-switch.test.ts @@ -51,100 +51,6 @@ function session(init: string) { } describe('the context ring across a model change', () => { - it.each([ - [SONNET, 200_000, 30], - [`${SONNET}[1m]`, 1_000_000, 6] - ])('divides an estimate by the window the report states (%s)', async (model, window, share) => { - const s = session(model) - s.handle(userFrame('turn-a', 1_000)) - s.handle(resultFrame(2_000)) - await s.reply(answer(model, 50_000, window)) - s.handle(initFrame(model, 2_900)) - s.handle(userFrame('turn-b', 3_000)) - s.handle(assistantFrame('reply-b', 4_000, 60_000, undefined, SONNET)) - expect(s.ring()).toEqual({ - usedTokens: 60_000, - windowTokens: window, - percentage: share, - estimated: true, - categories: [] - }) - expect(readAgentJournalTurn(s.turnRow('turn-a')!.body)?.contextUsage?.window).toEqual({ - tokens: window, - capturedAt: 90_000 - }) - expect(readAgentJournalTurn(s.turnRow('turn-b')!.body)?.contextUsage?.used).toEqual({ - kind: 'estimate', - usage: { - inputTokens: 60_000, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - outputTokens: 4 - }, - capturedAt: 4_000 - }) - s.translator.dispose() - }) - - it('blanks the ring at an idle switch between the 1M and 200k windows of one model', async () => { - const s = session(SONNET) - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 150_000, undefined, SONNET)) - s.handle(resultFrame(3_000, { [SONNET]: { contextWindow: 200_000 } })) - await s.reply(answer(SONNET, 151_000, 200_000)) - expect(s.ring()).toMatchObject({ windowTokens: 200_000, percentage: 76 }) - - const requests = s.pending.length - s.translator.modelMayHaveChanged() - expect(s.turnRow('turn-a')?.body).toMatchObject({ contextUsage: { used: { kind: 'unknown' } } }) - expect(s.ring()).toBeNull() - expect(s.pending).toHaveLength(requests + 1) - await s.reply(answer(`${SONNET}[1m]`, 151_000, 1_000_000)) - expect(s.ring()).toMatchObject({ usedTokens: 151_000, windowTokens: 1_000_000 }) - - s.handle(initFrame(`${SONNET}[1m]`, 3_900)) - s.handle(userFrame('turn-b', 4_000)) - s.handle(assistantFrame('reply-b', 5_000, 160_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ usedTokens: 160_000, windowTokens: 1_000_000, percentage: 16 }) - s.handle(resultFrame(6_000, { [`${SONNET}[1m]`]: { contextWindow: 1_000_000 } })) - await s.reply(answer(`${SONNET}[1m]`, 161_000, 1_000_000)) - - s.translator.modelMayHaveChanged() - expect(s.ring()).toBeNull() - await s.reply(answer(SONNET, 161_000, 200_000)) - expect(s.ring()).toMatchObject({ windowTokens: 200_000, percentage: 81 }) - s.translator.dispose() - }) - - it('follows an idle plan toggle under opusplan to the Opus window and back', async () => { - const s = session(SONNET) - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 50_000, undefined, SONNET)) - s.handle(resultFrame(3_000, { [SONNET]: PLAN_USAGE[SONNET] })) - await s.reply(answer(SONNET, 51_000, 200_000)) - - s.translator.modelMayHaveChanged() - expect(s.ring()).toBeNull() - await s.reply(answer(`${OPUS}[1m]`, 51_000, 1_000_000)) - expect(s.ring()).toMatchObject({ windowTokens: 1_000_000, estimated: false }) - s.handle(initFrame(SONNET, 3_900)) - s.handle(userFrame('turn-b', 4_000)) - s.handle(assistantFrame('reply-b', 5_000, 60_000, undefined, OPUS)) - expect(s.ring()).toMatchObject({ usedTokens: 60_000, windowTokens: 1_000_000, percentage: 6 }) - s.handle(resultFrame(6_000, PLAN_USAGE)) - expect(s.ring()).toMatchObject({ usedTokens: 60_000, windowTokens: 1_000_000 }) - await s.reply(answer(`${OPUS}[1m]`, 61_000, 1_000_000)) - - s.translator.modelMayHaveChanged() - expect(s.ring()).toBeNull() - await s.reply(answer(SONNET, 61_000, 200_000)) - s.handle(initFrame(SONNET, 6_900)) - s.handle(userFrame('turn-c', 7_000)) - s.handle(assistantFrame('reply-c', 8_000, 70_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ usedTokens: 70_000, windowTokens: 200_000, percentage: 35 }) - s.translator.dispose() - }) - it('holds estimates from the moment an approved plan hands the turn to another model', async () => { const s = session(SONNET) s.handle(userFrame('turn-a', 1_000)) @@ -178,78 +84,6 @@ describe('the context ring across a model change', () => { s.translator.dispose() }) - it('clears the hold at the turn result when the report a mid-turn write asked for never answers', async () => { - const s = session(SONNET) - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 50_000, undefined, SONNET)) - s.handle(resultFrame(3_000, { [SONNET]: { contextWindow: 200_000 } })) - await s.reply(answer(SONNET, 51_000, 200_000)) - s.handle(userFrame('turn-b', 4_000)) - s.handle(assistantFrame('reply-b1', 5_000, 60_000, undefined, SONNET)) - - s.translator.modelMayHaveChanged() - const unanswered = s.pending.length - 1 - s.handle(assistantFrame('reply-b2', 6_000, 70_000, undefined, SONNET)) - expect(s.ring()).toBeNull() - s.handle(assistantFrame('reply-b3', 7_000, 80_000, undefined, SONNET)) - expect(s.ring()).toBeNull() - expect(s.unknownWrites()).toBe(1) - expect(s.pending).toHaveLength(unanswered + 1) - - s.handle(resultFrame(8_000, { [SONNET]: { contextWindow: 200_000 } })) - await s.reply(undefined) - expect(s.ring()).toBeNull() - s.handle(userFrame('turn-c', 9_000)) - s.handle(assistantFrame('reply-c', 10_000, 90_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ usedTokens: 90_000, windowTokens: 200_000, percentage: 45 }) - s.translator.dispose() - }) - - it('lets a later turn result restate the window an earlier report stated', async () => { - const s = session(`${SONNET}[1m]`) - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 140_000, undefined, SONNET)) - s.handle(resultFrame(3_000)) - await s.reply(answer(`${SONNET}[1m]`, 141_000, 1_000_000)) - expect(s.ring()).toMatchObject({ windowTokens: 1_000_000 }) - - s.handle(initFrame(SONNET, 3_900)) - s.handle(userFrame('turn-b', 4_000)) - s.handle(assistantFrame('reply-b', 5_000, 150_000, undefined, SONNET)) - s.handle( - resultFrame(6_000, { - [`${SONNET}[1m]`]: { contextWindow: 1_000_000 }, - [SONNET]: { contextWindow: 200_000 } - }) - ) - await s.reply(undefined) - expect(s.ring()).toMatchObject({ usedTokens: 150_000, windowTokens: 200_000, percentage: 75 }) - s.translator.dispose() - }) - - it('keeps the window of a switch the user sends straight after', async () => { - const s = session(SONNET) - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 150_000, undefined, SONNET)) - s.handle(resultFrame(3_000, { [SONNET]: { contextWindow: 200_000 } })) - await s.reply(answer(SONNET, 151_000, 200_000)) - - s.translator.modelMayHaveChanged() - const switched = s.pending.length - 1 - s.translator.markContextActivity() - s.handle(initFrame(`${SONNET}[1m]`, 3_900)) - s.handle(userFrame('turn-b', 4_000)) - await s.reply(answer(`${SONNET}[1m]`, 151_000, 1_000_000), switched) - expect(s.ring()).toBeNull() - expect(s.turnRow('turn-b')?.body).toMatchObject({ - contextUsage: { window: { tokens: 1_000_000 } } - }) - expect(readAgentJournalTurn(s.turnRow('turn-b')!.body)?.contextUsage?.used).toBeUndefined() - s.handle(assistantFrame('reply-b', 5_000, 160_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ usedTokens: 160_000, windowTokens: 1_000_000, percentage: 16 }) - s.translator.dispose() - }) - it('does not let a turn result undo the window a mid-turn switch between 1M and 200k reported', async () => { const s = session(`${SONNET}[1m]`) s.handle(userFrame('turn-a', 1_000)) diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index e0726b95a79..b8eff4b3c67 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -174,29 +174,6 @@ describe('context usage across a restart', () => { after.release() }) - it('reads the ring from the journal after a restart, and adopts the first response model', async () => { - const journal = await openJournal() - const before = acquire(journal) - before.translator.handle(initFrame(900)) - before.translator.handle(userFrame('turn-a', 1_000)) - before.translator.handle(assistantFrame('reply-a', 2_000, 150_000)) - before.translator.handle(resultFrame(3_000)) - await before.settle() - before.release() - - const after = acquire(journal) - expect(ring(journal)).toMatchObject({ usedTokens: 150_000, windowTokens: 1_000_000 }) - after.translator.handle(initFrame(9_900)) - after.translator.handle(userFrame('turn-b', 10_000)) - after.translator.handle(assistantFrame('reply-b', 11_000, 160_000)) - await after.settle() - expect(ring(journal)).toMatchObject({ usedTokens: 160_000, windowTokens: 1_000_000 }) - after.translator.handle(assistantFrame('reply-b2', 12_000, 170_000, 'claude-sonnet-5')) - await after.settle() - expect(ring(journal)).toBeNull() - after.release() - }) - it('never brings a settled turn back to running with a context write that ran late', async () => { const journal = await openJournal() const live = acquire(journal) @@ -225,25 +202,4 @@ describe('context usage across a restart', () => { }) live.release() }) - - it('keeps the window a restarted journal holds over the one the model name implies', async () => { - const journal = await openJournal() - const before = acquire(journal) - before.translator.handle(initFrame(900)) - before.translator.handle(userFrame('turn-a', 1_000)) - before.translator.handle(assistantFrame('reply-a', 2_000, 150_000)) - before.translator.handle(resultFrame(3_000, 400_000)) - await before.settle() - before.release() - - const after = acquire(journal) - after.translator.modelWritten('fable[1m]') - after.translator.handle(initFrame(9_900)) - after.translator.handle(userFrame('turn-b', 10_000)) - after.translator.handle(assistantFrame('reply-b', 11_000, 160_000)) - await after.settle() - expect(ring(journal)).toMatchObject({ usedTokens: 160_000, windowTokens: 400_000 }) - expect(journal.contextUsage().window).toEqual({ tokens: 400_000, capturedAt: 3_000 }) - after.release() - }) }) diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 4f4b2f25f6a..852499e528a 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -10,10 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionHistoryPage } from '../../shared/agent-session/agent-session-wire' import { readAgentJournalTurn } from '../../shared/agent-session/lifecycle/agent-session-turn-record' import { MAX_RETAINED_OWN_ITEMS } from '../../shared/agent-session/view/structured-agent-session-item-retention' -import { - latestStructuredAgentContextFacts, - selectStructuredAgentContextUsage -} from '../../shared/agent-session/structured-agent-session-context-usage' +import { selectStructuredAgentContextUsage } from '../../shared/agent-session/structured-agent-session-context-usage' import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession, @@ -34,10 +31,7 @@ import { import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' -import { - claudeAndCodexAgents, - NO_STRUCTURED_AGENTS -} from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import type { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' const SESSION = 'orca-session' @@ -174,41 +168,6 @@ function catchUp( } describe('context usage for a turn row outside the loaded page', () => { - it('shows the ring on a reopened chat from the host whole-journal answer', async () => { - const journal = await openJournal() - const { translator, settle } = translate(journal) - runTurn(translator, 'turn-a', 1_000, [120_000, 130_000, 140_000, 150_000, 160_000]) - translator.handle(resultFrame(2_000, { 'claude-fable-5-1[1m]': { contextWindow: 1_000_000 } })) - await settle() - - const state = attachTail(journal, 3) - expect(state.hasOlder).toBe(true) - expect(hasTurnRow(state)).toBe(false) - const options = await readOptions(journal, claudeAndCodexAgents()) - - expect(options.contextUsage?.current).toEqual( - latestStructuredAgentContextFacts(journal.snapshot().items) - ) - expect(selectStructuredAgentContextUsage(state.items, options.contextUsage?.current)).toEqual( - selectStructuredAgentContextUsage(journal.snapshot().items) - ) - expect( - selectStructuredAgentContextUsage(state.items, options.contextUsage?.current) - ).toMatchObject({ usedTokens: 160_000, windowTokens: 1_000_000, estimated: true }) - }) - - it('leaves the options answer untouched on a session that writes no context facts', async () => { - const journal = await openJournal() - const { translator, settle } = translate(journal) - runTurn(translator, 'turn-a', 1_000, [120_000, 130_000, 140_000]) - await settle() - - const options = await readOptions(journal, NO_STRUCTURED_AGENTS) - expect(options).not.toHaveProperty('contextUsage') - // Older hosts omit context facts, so clients fall back to loaded rows. - expect(selectStructuredAgentContextUsage(attachTail(journal, 2).items)).toBeNull() - }) - it('marks a live context write on a turn row the window cannot take, and the host answer carries it', async () => { const journal = await openJournal() const { translator, settle } = translate(journal) diff --git a/src/main/claude/claude-context-usage.test.ts b/src/main/claude/claude-context-usage.test.ts deleted file mode 100644 index ac88abd4fb3..00000000000 --- a/src/main/claude/claude-context-usage.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionContextReport } from '../../shared/agent-session/agent-session-context-usage' -import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' -import { - bindClaudeContextUsageCapture, - claudeContextReportFromControl, - claudeContextWindowFromResult, - claudeTokenUsage -} from './claude-context-usage' -import { claudeTurnLifecycleIdentity } from './claude-turn-lifecycle-item' - -// Shape measured from Claude Code 2.1.270 / Agent SDK 0.3.251. -const CONTROL_REPORT = { - categories: [ - { name: 'System prompt', tokens: 3_800, color: 'x' }, - { name: 'System tools (deferred)', tokens: 15_500, color: 'x', isDeferred: true }, - { name: 'Messages', tokens: 10, color: 'x' }, - { name: 'Autocompact buffer', tokens: 33_000, color: 'x' }, - { name: 'Free space', tokens: 981_400, color: 'x' } - ], - totalTokens: 18_600, - maxTokens: 967_000, - rawMaxTokens: 1_000_000, - percentage: 2, - gridRows: [], - model: 'claude-fable-5-1[1m]', - memoryFiles: [], - mcpTools: [], - agents: [], - autoCompactThreshold: 967_000, - isAutoCompactEnabled: true -} - -describe('claudeContextReportFromControl', () => { - it('keeps the CLI rows as it names and counts them', () => { - expect(claudeContextReportFromControl(CONTROL_REPORT, 42)).toEqual({ - model: 'claude-fable-5-1[1m]', - usedTokens: 18_600, - windowTokens: 1_000_000, - percentage: 2, - autoCompactAtTokens: 967_000, - categories: [ - { name: 'System prompt', tokens: 3_800 }, - { name: 'System tools (deferred)', tokens: 15_500, deferred: true }, - { name: 'Messages', tokens: 10 }, - { name: 'Autocompact buffer', tokens: 33_000 }, - { name: 'Free space', tokens: 981_400 } - ], - capturedAt: 42 - }) - }) - - it('omits the compaction threshold when auto-compaction is off', () => { - expect( - claudeContextReportFromControl({ ...CONTROL_REPORT, isAutoCompactEnabled: false }, 1) - ?.autoCompactAtTokens - ).toBeUndefined() - }) - - it('rejects reports that name no model or window', () => { - expect(claudeContextReportFromControl({}, 1)).toBeNull() - expect(claudeContextReportFromControl(null, 1)).toBeNull() - expect( - claudeContextReportFromControl({ ...CONTROL_REPORT, rawMaxTokens: 0, maxTokens: 0 }, 1) - ).toBeNull() - expect( - claudeContextReportFromControl({ ...CONTROL_REPORT, rawMaxTokens: undefined }, 1) - ?.windowTokens - ).toBe(967_000) - }) -}) - -describe('claudeContextWindowFromResult', () => { - it('reads the largest window when nothing names the main thread model, in any order', () => { - const main = { contextWindow: 1_000_000 } - const side = { contextWindow: 200_000 } - expect( - claudeContextWindowFromResult({ modelUsage: { 'claude-fable-5-1[1m]': main, haiku: side } }) - ).toBe(1_000_000) - expect( - claudeContextWindowFromResult({ modelUsage: { haiku: side, 'claude-fable-5-1[1m]': main } }) - ).toBe(1_000_000) - }) - - it('reads the entry of the model that served the main thread, not a larger one', () => { - const modelUsage = { - 'claude-fable-5-1[1m]': { contextWindow: 1_000_000 }, - 'claude-sonnet-5': { contextWindow: 200_000 } - } - const byResponse = (responseModel: string) => - claudeContextWindowFromResult({ modelUsage }, { initModel: null, responseModel }) - expect(byResponse('claude-sonnet-5')).toBe(200_000) - expect(byResponse('claude-fable-5-1')).toBe(1_000_000) - expect( - claudeContextWindowFromResult( - { - modelUsage: { - 'us.anthropic.claude-sonnet-5-v1': { - contextWindow: 200_000, - canonicalModel: 'claude-sonnet-5' - }, - 'claude-fable-5-1[1m]': { contextWindow: 1_000_000 } - } - }, - { initModel: null, responseModel: 'claude-sonnet-5' } - ) - ).toBe(200_000) - }) - - it('reads the exact entry the turn init names when the model runs with and without [1m]', () => { - const modelUsage = { - 'claude-fable-5-1[1m]': { contextWindow: 1_000_000 }, - 'claude-fable-5-1': { contextWindow: 200_000 } - } - const responseModel = 'claude-fable-5-1' - expect( - claudeContextWindowFromResult( - { modelUsage }, - { initModel: 'claude-fable-5-1', responseModel } - ) - ).toBe(200_000) - expect( - claudeContextWindowFromResult( - { modelUsage }, - { initModel: 'claude-fable-5-1[1m]', responseModel } - ) - ).toBe(1_000_000) - expect( - claudeContextWindowFromResult( - { modelUsage: { ...modelUsage, 'claude-sonnet-5': { contextWindow: 300_000 } } }, - { initModel: 'claude-fable-5-1[1m]', responseModel: 'claude-sonnet-5' } - ) - ).toBe(300_000) - }) - - it('reads the plan-mode model the init does not name', () => { - // Shape measured from Claude Code 2.1.280 with `--model opusplan` in plan mode. - const modelUsage = { - 'claude-sonnet-5': { contextWindow: 200_000 }, - 'claude-opus-5-5[1m]': { contextWindow: 1_000_000 } - } - expect( - claudeContextWindowFromResult( - { modelUsage }, - { initModel: 'claude-sonnet-5', responseModel: 'claude-opus-5-5' } - ) - ).toBe(1_000_000) - expect( - claudeContextWindowFromResult( - { modelUsage }, - { initModel: 'claude-sonnet-5', responseModel: 'claude-sonnet-5' } - ) - ).toBe(200_000) - }) - - it('is null when no entry reports a usable window', () => { - expect(claudeContextWindowFromResult({ type: 'result' })).toBeNull() - expect( - claudeContextWindowFromResult({ - type: 'result', - modelUsage: { a: { contextWindow: 0 }, b: { contextWindow: 'big' }, c: null } - }) - ).toBeNull() - }) -}) - -describe('claudeTokenUsage', () => { - it('drops the all-zero usage the CLI stamps on rows it synthesizes', () => { - expect(claudeTokenUsage({ input_tokens: 0, output_tokens: 0 })).toBeNull() - expect(claudeTokenUsage({ input_tokens: 3, cache_read_input_tokens: 7 })).toEqual({ - inputTokens: 3, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 7, - outputTokens: 0 - }) - }) -}) - -const TURN_1 = claudeTurnLifecycleIdentity('claude-session', 'turn-1') -const TURN_2 = claudeTurnLifecycleIdentity('claude-session', 'turn-2') - -function fakeTranslator() { - const listeners = new Set<(target: ClaudeContextReportTarget) => void>() - const annotations: [ - ClaudeContextReportTarget, - AgentSessionContextReport, - ClaudeContextReportPart - ][] = [] - const state = { activity: 0 } - const request = (target: ClaudeContextReportTarget): void => { - for (const listener of listeners) { - listener(target) - } - } - return { - listeners, - annotations, - state, - request, - translator: { - get contextActivity() { - return state.activity - }, - subscribeContextUsageRequests: (listener: (target: ClaudeContextReportTarget) => void) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - recordContextReport: ( - target: ClaudeContextReportTarget, - report: AgentSessionContextReport, - part: ClaudeContextReportPart - ) => { - annotations.push([target, report, part]) - } - } - } -} - -function deferred(): { promise: Promise; resolve: (value: unknown) => void } { - let resolve: (value: unknown) => void = () => {} - const promise = new Promise((settle) => (resolve = settle)) - return { promise, resolve } -} - -const settle = async (): Promise => { - for (let index = 0; index < 4; index += 1) { - await Promise.resolve() - } -} - -describe('bindClaudeContextUsageCapture', () => { - it('records the answer on the turn the request named, with a bounded wait', async () => { - const fake = fakeTranslator() - const getContextUsage = vi.fn(async () => CONTROL_REPORT) - bindClaudeContextUsageCapture({ getContextUsage }, fake.translator, { now: () => 99 }) - fake.request(TURN_1) - await vi.waitFor(() => expect(fake.annotations).toHaveLength(1)) - expect(getContextUsage).toHaveBeenCalledWith({ timeoutMs: 5_000 }) - expect(fake.annotations[0]).toEqual([ - TURN_1, - expect.objectContaining({ usedTokens: 18_600, capturedAt: 99 }), - 'report' - ]) - }) - - it('keeps only the window of an answer the conversation moved past while it was in flight', async () => { - const fake = fakeTranslator() - const answer = deferred() - bindClaudeContextUsageCapture({ getContextUsage: () => answer.promise }, fake.translator, {}) - fake.request(TURN_1) - fake.state.activity += 1 - answer.resolve(CONTROL_REPORT) - await settle() - expect(fake.annotations).toEqual([ - [TURN_1, expect.objectContaining({ windowTokens: 1_000_000 }), 'window'] - ]) - }) - - it('drops an answer a newer request superseded, keeping the newer one', async () => { - const fake = fakeTranslator() - const first = deferred() - const second = deferred() - const getContextUsage = vi - .fn() - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise) - bindClaudeContextUsageCapture({ getContextUsage }, fake.translator, { now: () => 5 }) - fake.request(TURN_1) - fake.request(TURN_1) - second.resolve({ ...CONTROL_REPORT, totalTokens: 9_000 }) - await settle() - fake.state.activity += 1 - first.resolve(CONTROL_REPORT) - await settle() - expect(fake.annotations).toEqual([ - [TURN_1, expect.objectContaining({ usedTokens: 9_000 }), 'report'] - ]) - }) - - it('leaves the row alone when the CLI cannot answer or the binding was released', async () => { - const fake = fakeTranslator() - const late = deferred() - const getContextUsage = vi - .fn() - .mockRejectedValueOnce(new Error('older CLI')) - .mockReturnValueOnce(late.promise) - const unbind = bindClaudeContextUsageCapture({ getContextUsage }, fake.translator, {}) - fake.request(TURN_1) - await settle() - expect(fake.annotations).toHaveLength(0) - fake.request(TURN_2) - unbind?.() - late.resolve(CONTROL_REPORT) - await settle() - expect(fake.annotations).toHaveLength(0) - expect(fake.listeners.size).toBe(0) - }) -}) diff --git a/src/main/claude/claude-context-window-hint.test.ts b/src/main/claude/claude-context-window-hint.test.ts deleted file mode 100644 index 4afab3fb8de..00000000000 --- a/src/main/claude/claude-context-window-hint.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { selectStructuredAgentContextUsage } from '../../shared/agent-session/structured-agent-session-context-usage' -import { readAgentJournalTurn } from '../../shared/agent-session/lifecycle/agent-session-turn-record' -import { bindClaudeContextUsageCapture, claudeContextWindowHint } from './claude-context-usage' -import { - assistantFrame, - initFrame, - resultFrame, - setup, - userFrame -} from './claude-context-usage-test-support' - -const SONNET = 'claude-sonnet-5' -const OPUS = 'claude-opus-5-5' - -const answer = (model: string, totalTokens: number, rawMaxTokens: number) => ({ - model, - totalTokens, - rawMaxTokens, - categories: [] -}) - -function session(init: string, configured?: string) { - const t = setup({ init }) - if (configured !== undefined) { - t.translator.modelWritten(configured) - } - const pending: ((value: unknown) => void)[] = [] - bindClaudeContextUsageCapture( - { getContextUsage: () => new Promise((resolve) => pending.push(resolve)) }, - t.translator, - { now: () => 90_000 } - ) - const reply = async (value: unknown, request = pending.length - 1): Promise => { - pending[request]?.(value) - for (let index = 0; index < 4; index += 1) { - await Promise.resolve() - } - } - const ring = () => selectStructuredAgentContextUsage(t.items()) - return { ...t, pending, reply, ring } -} - -describe('the window a configured model name implies', () => { - it.each([ - ['opus', null], - ['sonnet', null], - ['fable', null], - ['best', null], - ['claude-fable-5-1', null], - ['claude-opus-5-5', null], - ['claude-sonnet-5', null], - ['opusplan', null], - ['haiku', null], - ['default', null], - ['', null], - ['opus[1m]', 1_000_000], - ['sonnet[1m]', 1_000_000], - ['fable[1m]', 1_000_000], - ['opusplan[1m]', 1_000_000], - ['claude-opus-5-5[1m]', 1_000_000], - ['Sonnet[1M]', 1_000_000] - ])('%s → %s', (model, window) => { - expect(claudeContextWindowHint(model)).toBe(window) - }) -}) - -describe('the context ring before the first window is measured', () => { - it.each([ - ['opus[1m]', `${OPUS}[1m]`, OPUS, 1_000_000, 5], - ['sonnet[1m]', `${SONNET}[1m]`, SONNET, 1_000_000, 5] - ])( - 'sizes the first response of a new session from its model (%s)', - (configured, init, responder, window, share) => { - const s = session(init, configured) - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 50_000, undefined, responder)) - expect(s.ring()).toEqual({ - usedTokens: 50_000, - windowTokens: window, - percentage: share, - estimated: true, - categories: [] - }) - expect(readAgentJournalTurn(s.turnRow('turn-a')!.body)?.contextUsage?.window).toEqual({ - tokens: window, - capturedAt: 2_000 - }) - s.translator.dispose() - } - ) - - it('looks for a window already in the journal only on the first response', () => { - const s = session(`${SONNET}[1m]`, 'sonnet[1m]') - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a1', 2_000, 50_000, undefined, SONNET)) - s.handle(assistantFrame('reply-a2', 3_000, 60_000, undefined, SONNET)) - s.handle(assistantFrame('reply-a3', 4_000, 70_000, undefined, SONNET)) - expect(s.scans.count).toBe(1) - expect(s.ring()).toMatchObject({ usedTokens: 70_000, windowTokens: 1_000_000 }) - s.translator.dispose() - }) - - it.each(['default', 'opus', 'opusplan'])( - 'shows no ring under `%s` until the result states the window', - (configured) => { - const s = session(`${OPUS}[1m]`, configured) - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 50_000, undefined, OPUS)) - expect(s.ring()).toBeNull() - s.handle(resultFrame(3_000, { [`${OPUS}[1m]`]: { contextWindow: 1_000_000 } })) - expect(s.ring()).toMatchObject({ usedTokens: 50_000, windowTokens: 1_000_000 }) - s.translator.dispose() - } - ) - - it.each([ - ['sonnet[1m]', `${SONNET}[1m]`, 1_000_000, 5], - ['sonnet', SONNET, 200_000, 25] - ])( - 'sizes the first response from the report a pick of %s made before any turn', - async (configured, reported, window, share) => { - const s = session(reported) - s.translator.modelMayHaveChanged() - s.translator.modelWritten(configured) - await s.reply(answer(reported, 20_000, window)) - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 50_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ - usedTokens: 50_000, - windowTokens: window, - percentage: share - }) - s.translator.dispose() - } - ) - - it('lets the result and then the report replace the implied window', async () => { - const s = session(`${SONNET}[1m]`, 'sonnet[1m]') - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 100_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) - s.handle(resultFrame(3_000, { [SONNET]: { contextWindow: 400_000 } })) - expect(s.ring()).toMatchObject({ usedTokens: 100_000, windowTokens: 400_000, percentage: 25 }) - await s.reply(answer(SONNET, 101_000, 500_000)) - expect(s.ring()).toMatchObject({ usedTokens: 101_000, windowTokens: 500_000, estimated: false }) - s.handle(initFrame(SONNET, 3_900)) - s.handle(userFrame('turn-b', 4_000)) - s.handle(assistantFrame('reply-b', 5_000, 110_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ usedTokens: 110_000, windowTokens: 500_000, estimated: true }) - s.translator.dispose() - }) - - it('keeps estimates flowing against a written model straight away, until its report lands', async () => { - const s = session(SONNET, 'sonnet') - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 150_000, undefined, SONNET)) - s.handle(resultFrame(3_000, { [SONNET]: { contextWindow: 200_000 } })) - await s.reply(answer(SONNET, 151_000, 200_000)) - expect(s.ring()).toMatchObject({ windowTokens: 200_000, percentage: 76 }) - - s.translator.modelMayHaveChanged() - s.translator.modelWritten('sonnet[1m]') - const switched = s.pending.length - 1 - expect(s.ring()).toBeNull() - s.handle(initFrame(`${SONNET}[1m]`, 3_900)) - s.handle(userFrame('turn-b', 4_000)) - s.handle(assistantFrame('reply-b', 5_000, 160_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ usedTokens: 160_000, windowTokens: 1_000_000, percentage: 16 }) - - await s.reply(answer(`${SONNET}[1m]`, 161_000, 900_000), switched) - expect(s.turnRow('turn-b')?.body).toMatchObject({ - contextUsage: { window: { tokens: 900_000 } } - }) - s.handle(assistantFrame('reply-b2', 6_000, 180_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ usedTokens: 180_000, windowTokens: 900_000, percentage: 20 }) - s.translator.dispose() - }) - - it('still hides the ring across a permission-mode change until the report lands', async () => { - const s = session(`${SONNET}[1m]`, 'sonnet[1m]') - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a', 2_000, 50_000, undefined, SONNET)) - expect(s.ring()).toMatchObject({ windowTokens: 1_000_000 }) - - s.translator.modelMayHaveChanged() - const toggled = s.pending.length - 1 - s.handle(assistantFrame('reply-a2', 3_000, 60_000, undefined, SONNET)) - expect(s.ring()).toBeNull() - await s.reply(answer(`${OPUS}[1m]`, 61_000, 1_000_000), toggled) - s.handle(assistantFrame('reply-a3', 4_000, 70_000, undefined, OPUS)) - expect(s.ring()).toMatchObject({ usedTokens: 70_000, windowTokens: 1_000_000, percentage: 7 }) - s.translator.dispose() - }) - - it('still hides the ring when a response moves to another model mid-turn', () => { - const s = session(`${SONNET}[1m]`, 'opusplan[1m]') - s.handle(userFrame('turn-a', 1_000)) - s.handle(assistantFrame('reply-a1', 2_000, 50_000, undefined, OPUS)) - expect(s.ring()).toMatchObject({ windowTokens: 1_000_000 }) - s.handle(assistantFrame('reply-a2', 3_000, 60_000, undefined, SONNET)) - expect(s.ring()).toBeNull() - s.handle(assistantFrame('reply-a3', 4_000, 70_000, undefined, SONNET)) - expect(s.ring()).toBeNull() - s.translator.dispose() - }) -}) diff --git a/src/main/claude/claude-fold-steer-receipt.test.ts b/src/main/claude/claude-fold-steer-receipt.test.ts index 7252cca5659..42f8b0ebc26 100644 --- a/src/main/claude/claude-fold-steer-receipt.test.ts +++ b/src/main/claude/claude-fold-steer-receipt.test.ts @@ -28,18 +28,7 @@ import { type CapturedFoldFrame } from './claude-fold-steer-frame-builders.test-fixture' import { - backgroundWakeCapture, - cancelCapture, - CANCEL_SEND_AT, - earlySteerCapture, - EARLY_STEER_SEND_AT, FIRST_PROMPT, - foldFreshCapture, - FOLD_FRESH_SEND_AT, - foldResumedCapture, - FOLD_RESUMED_SEND_AT, - missCapture, - MISS_SEND_AT, SECOND_STEER_PROMPT, STEER_PROMPT, twoSteersCapture, @@ -142,101 +131,6 @@ function replayEventFor(events: ClaudeStructuredSessionEvent[], uuid: string) { } describe('Claude fold receipt for a mid-turn send (captured orders)', () => { - it('fold-fresh: the folded steer settles as a receipt inside the one turn it was sent during', async () => { - const rig = await riggedAdapter() - const first = await rig.dispatchAt(FOLD_FRESH_SEND_AT.first, 'client-first', FIRST_PROMPT) - const framesFor = (steerUuid: string) => - foldFreshCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) - for (const captured of framesFor('pending')) { - if (captured.at < FOLD_FRESH_SEND_AT.steer) { - rig.deliver(captured) - } - } - const steer = await rig.dispatchAt(FOLD_FRESH_SEND_AT.steer, 'client-steer', STEER_PROMPT) - for (const captured of framesFor(steer)) { - if (captured.at > FOLD_FRESH_SEND_AT.steer) { - rig.deliver(captured) - } - } - - const turnIds = [...new Set(rig.turns().map((turn) => turn.turnId))] - expect(turnIds).toEqual([first]) - expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) - expect(rig.turns().at(-1)).toMatchObject({ - turnId: first, - state: 'completed', - outcome: 'success', - startedAt: T0 + 2_094, - completedAt: T0 + 23_048, - durationMs: 22_845 - }) - expect(rig.settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-steer', - providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } - }) - expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') - expect(replayEventFor(rig.events, first)).toMatchObject({ startsTurn: true }) - }) - - it('session-start proof: a hook-frame startup proof with init only at the first cycle still folds', async () => { - // Captured SessionStart proof precedes init; fold detection must not depend on proof-frame shape. - const rig = await riggedAdapter({}, { initProof: 'session-start' }) - const first = await rig.dispatchAt(FOLD_FRESH_SEND_AT.first, 'client-first', FIRST_PROMPT) - const framesFor = (steerUuid: string) => - foldFreshCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) - for (const captured of framesFor('pending')) { - if (captured.at < FOLD_FRESH_SEND_AT.steer) { - rig.deliver(captured) - } - } - const steer = await rig.dispatchAt(FOLD_FRESH_SEND_AT.steer, 'client-steer', STEER_PROMPT) - for (const captured of framesFor(steer)) { - if (captured.at > FOLD_FRESH_SEND_AT.steer) { - rig.deliver(captured) - } - } - - expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) - expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) - expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') - }) - - it('fold-resumed: the receipt holds on a resumed provider session', async () => { - const rig = await riggedAdapter({ - resumeLeafUuid: 'leaf-1', - resumesTranscript: true, - continuesChain: true - }) - const first = await rig.dispatchAt(FOLD_RESUMED_SEND_AT.first, 'client-first', FIRST_PROMPT) - const framesFor = (steerUuid: string) => - foldResumedCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) - for (const captured of framesFor('pending')) { - if (captured.at < FOLD_RESUMED_SEND_AT.steer) { - rig.deliver(captured) - } - } - const steer = await rig.dispatchAt(FOLD_RESUMED_SEND_AT.steer, 'client-steer', STEER_PROMPT) - for (const captured of framesFor(steer)) { - if (captured.at > FOLD_RESUMED_SEND_AT.steer) { - rig.deliver(captured) - } - } - expect(rig.settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-steer', - providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } - }) - - expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) - expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) - expect(rig.turns().at(-1)).toMatchObject({ - turnId: first, - state: 'completed', - durationMs: 23_367 - }) - }) - it('two-steers: both folded sends settle individually inside the one turn', async () => { const rig = await riggedAdapter() const first = await rig.dispatchAt(TWO_STEERS_SEND_AT.first, 'client-first', FIRST_PROMPT) @@ -278,89 +172,6 @@ describe('Claude fold receipt for a mid-turn send (captured orders)', () => { }) }) - it('early-steer: a steer written before the first replay arrives still folds into the one turn', async () => { - const rig = await riggedAdapter() - const first = await rig.dispatchAt(EARLY_STEER_SEND_AT.first, 'client-first', FIRST_PROMPT) - const framesFor = (steerUuid: string) => - earlySteerCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) - for (const captured of framesFor('pending')) { - if (captured.at < EARLY_STEER_SEND_AT.steer) { - rig.deliver(captured) - } - } - const steer = await rig.dispatchAt(EARLY_STEER_SEND_AT.steer, 'client-steer', STEER_PROMPT) - for (const captured of framesFor(steer)) { - if (captured.at > EARLY_STEER_SEND_AT.steer) { - rig.deliver(captured) - } - } - - expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) - expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) - expect(rig.turns().at(-1)).toMatchObject({ - turnId: first, - state: 'completed', - durationMs: 7_155 - }) - expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') - expect(rig.settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-steer', - providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } - }) - }) - - it('background-wake: the wake cycle opens from its output and its result settles no waiter', async () => { - const rig = await riggedAdapter() - const first = await rig.dispatchAt(13, 'client-first', FIRST_PROMPT) - const { firstTurn, wake } = backgroundWakeCapture({ - sessionId: PROVIDER_SESSION_ID, - first, - steer: 'unused' - }) - for (const captured of [...firstTurn, ...wake]) { - rig.deliver(captured) - } - - // Captured task completion precedes wake init and opens the wake turn. - const finalByTurn = new Map(rig.turns().map((turn) => [turn.turnId, turn])) - expect([...finalByTurn.keys()]).toEqual([first, 'task_updated-17547']) - expect(finalByTurn.get('task_updated-17547')).toMatchObject({ state: 'completed' }) - expect(rig.settled).toHaveBeenCalledTimes(1) - }) - - it('background-wake: a steer replayed after the wake cycle began work folds into the wake turn', async () => { - const rig = await riggedAdapter() - const first = await rig.dispatchAt(13, 'client-first', FIRST_PROMPT) - const { firstTurn, wake } = backgroundWakeCapture({ - sessionId: PROVIDER_SESSION_ID, - first, - steer: 'pending' - }) - const wakeResult = wake.findIndex((captured) => captured.frame.type === 'result') - for (const captured of [...firstTurn, ...wake.slice(0, wakeResult)]) { - rig.deliver(captured) - } - // Add a mid-cycle replay to the captured wake order, matching the p3 early-steer shape. - const steer = await rig.dispatchAt(19_725, 'client-steer', STEER_PROMPT) - rig.deliver(userReplay(19_726, PROVIDER_SESSION_ID, steer, STEER_PROMPT)) - for (const captured of wake.slice(wakeResult)) { - rig.deliver(captured) - } - - expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([ - first, - 'task_updated-17547' - ]) - expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) - expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') - expect(rig.settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-steer', - providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } - }) - }) - it('mid-turn auto-compaction emits no root init, so a steer after the boundary still folds', async () => { // Captured with forced auto-compaction (p4): compacting, boundary, and synthetic continuation have no root init. const rig = await riggedAdapter() @@ -422,112 +233,9 @@ describe('Claude fold receipt for a mid-turn send (captured orders)', () => { providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } }) }) - - it('miss: a steer whose replay trails the result keeps the opener path and its own turn', async () => { - const rig = await riggedAdapter() - const first = await rig.dispatchAt(MISS_SEND_AT.first, 'client-first', FIRST_PROMPT) - const { beforeSteerSend } = missCapture({ - sessionId: PROVIDER_SESSION_ID, - first, - steer: 'pending' - }) - for (const captured of beforeSteerSend) { - rig.deliver(captured) - } - const steer = await rig.dispatchAt(MISS_SEND_AT.steer, 'client-steer', STEER_PROMPT) - for (const captured of missCapture({ sessionId: PROVIDER_SESSION_ID, first, steer }) - .afterSteerSend) { - rig.deliver(captured) - } - - expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first, steer]) - expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) - const finalByTurn = new Map(rig.turns().map((turn) => [turn.turnId, turn])) - expect(finalByTurn.get(first)).toMatchObject({ - state: 'completed', - completedAt: T0 + 25_556, - durationMs: 25_241 - }) - expect(finalByTurn.get(steer)).toMatchObject({ - state: 'completed', - startedAt: T0 + 28_703, - completedAt: T0 + 29_275, - durationMs: 3_711 - }) - expect(replayEventFor(rig.events, steer)).toMatchObject({ startsTurn: true }) - }) - - it('cancel: a cancelled steer settles nothing and the receipt path changes none of it', async () => { - const rig = await riggedAdapter() - const first = await rig.dispatchAt(CANCEL_SEND_AT.first, 'client-first', FIRST_PROMPT) - const { beforeSteerSend, afterInterrupt } = cancelCapture({ - sessionId: PROVIDER_SESSION_ID, - first, - steer: 'never-replayed' - }) - for (const captured of beforeSteerSend) { - rig.deliver(captured) - } - await rig.dispatchAt(CANCEL_SEND_AT.steer, 'client-steer', STEER_PROMPT) - for (const captured of afterInterrupt) { - rig.deliver(captured) - } - - expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) - expect(rig.turns().at(-1)).toMatchObject({ - turnId: first, - state: 'interrupted', - outcome: 'cancellation' - }) - expect(rig.settled).not.toHaveBeenCalledWith( - expect.objectContaining({ clientMessageId: 'client-steer' }) - ) - expect(replayEventFor(rig.events, 'interrupt-notice-1')).not.toHaveProperty('startsTurn') - }) }) describe('Claude fold receipt boundaries (synthetic orders)', () => { - it('lost result: a root init starts a cycle with no work yet, so the next adopted replay is a boundary', async () => { - const rig = await riggedAdapter() - const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') - rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) - const uuidB = await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) - rig.deliver(initFrame(2_000, PROVIDER_SESSION_ID)) - rig.deliver(userReplay(3_000, PROVIDER_SESSION_ID, uuidB, STEER_PROMPT)) - - expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([uuidA, uuidB]) - expect(replayEventFor(rig.events, uuidB)).toMatchObject({ startsTurn: true }) - }) - - it('provider wake: an adopted replay during the live wake cycle folds into the wake turn', async () => { - // Combine captured wake-cycle semantics (p3-background-wake) with a mid-cycle replay (p3-early-steer). - const rig = await riggedAdapter() - const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') - rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) - rig.deliver( - resultFrame(2_000, PROVIDER_SESSION_ID, 'result-1', { - userMessageUuids: [uuidA], - durationMs: 1_990, - numTurns: 1 - }) - ) - const uuidB = await rig.dispatchAt(2_500, 'client-b', STEER_PROMPT) - rig.deliver(initFrame(3_000, PROVIDER_SESSION_ID)) - rig.deliver(assistantText(3_500, PROVIDER_SESSION_ID, 'provider-resumed-1', 'background done')) - rig.deliver(userReplay(4_000, PROVIDER_SESSION_ID, uuidB, STEER_PROMPT)) - - expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([ - uuidA, - 'provider-resumed-1' - ]) - expect(replayEventFor(rig.events, uuidB)).not.toHaveProperty('startsTurn') - expect(rig.settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-b', - providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: uuidB } - }) - }) - it('a fresh replay uuid with user_message_uuid correlation keeps the opener path, not a fold receipt', async () => { const rig = await riggedAdapter() const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') @@ -545,31 +253,6 @@ describe('Claude fold receipt boundaries (synthetic orders)', () => { }) }) - it('plural result uuids: an unsettled folded waiter settles under its own uuid, never a shared result alias', async () => { - const rig = await riggedAdapter() - const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') - rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) - const uuidB = await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) - rig.deliver( - resultFrame(5_000, PROVIDER_SESSION_ID, 'result-1', { - userMessageUuids: [uuidA, uuidB], - durationMs: 4_990, - numTurns: 2 - }) - ) - - expect(rig.settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-b', - providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: uuidB } - }) - expect(rig.settled).not.toHaveBeenCalledWith( - expect.objectContaining({ providerIdentity: expect.objectContaining({ uuid: 'result-1' }) }) - ) - expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([uuidA]) - expect(rig.turns().at(-1)).toMatchObject({ turnId: uuidA, state: 'completed' }) - }) - it('plural result uuids: a result naming only settled sends does not claim an unnamed live waiter by queue order', async () => { const rig = await riggedAdapter() const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') diff --git a/src/main/claude/claude-folder-trust-file.test.ts b/src/main/claude/claude-folder-trust-file.test.ts index c8f22503094..b72e2de9d32 100644 --- a/src/main/claude/claude-folder-trust-file.test.ts +++ b/src/main/claude/claude-folder-trust-file.test.ts @@ -16,15 +16,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - applyClaudeFolderTrust, - grantClaudeFolderTrust, - grantClaudeWorkspaceTrust, - resolveClaudeGlobalConfigFile, - resolveLocalClaudeTrustConfig, - toClaudeTrustKey -} from './claude-folder-trust-file' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' +import { grantClaudeFolderTrust, grantClaudeWorkspaceTrust } from './claude-folder-trust-file' let root: string @@ -45,82 +37,6 @@ function readConfig(file: string): Record { return JSON.parse(readFileSync(file, 'utf-8')) } -describe('toClaudeTrustKey', () => { - it('NFC-normalises so a decomposed path matches the key Claude looks up', () => { - const decomposed = '/tmp/cafe\u0301' - expect(toClaudeTrustKey(decomposed, 'posix')).toBe('/tmp/caf\u00e9') - }) - - it('uses forward slashes on Windows, as Claude does', () => { - expect(toClaudeTrustKey('C:\\Users\\me\\wt\\', 'win32')).toBe('C:/Users/me/wt/') - expect(toClaudeTrustKey('C:\\Users\\me\\.\\wt', 'win32')).toBe('C:/Users/me/wt') - }) -}) - -describe('resolveClaudeGlobalConfigFile', () => { - const none = (): boolean => false - - it('defaults to ~/.claude.json, never ~/.claude/.claude.json', () => { - expect( - resolveClaudeGlobalConfigFile({ env: {}, homeDir: '/home/u', style: 'posix', exists: none }) - ).toBe('/home/u/.claude.json') - }) - - it('uses CLAUDE_CONFIG_DIR/.claude.json when set', () => { - expect( - resolveClaudeGlobalConfigFile({ - env: { CLAUDE_CONFIG_DIR: '/cfg' }, - homeDir: '/home/u', - style: 'posix', - exists: none - }) - ).toBe('/cfg/.claude.json') - }) - - it('prefers the legacy .config.json in the config dir', () => { - expect( - resolveClaudeGlobalConfigFile({ - env: {}, - homeDir: '/home/u', - style: 'posix', - exists: (p) => p === '/home/u/.claude/.config.json' - }) - ).toBe('/home/u/.claude/.config.json') - }) - - it('follows the custom-OAuth file suffix', () => { - expect( - resolveClaudeGlobalConfigFile({ - env: { CLAUDE_CODE_CUSTOM_OAUTH_URL: 'https://x' }, - homeDir: '/home/u', - style: 'posix', - exists: none - }) - ).toBe('/home/u/.claude-custom-oauth.json') - }) -}) - -describe('applyClaudeFolderTrust', () => { - it('is a no-op when the folder is already trusted', () => { - expect( - applyClaudeFolderTrust({ projects: { '/wt': { hasTrustDialogAccepted: true } } }, ['/wt']) - ).toEqual({ kind: 'unchanged' }) - }) - - it('refuses a non-object projects map instead of replacing it', () => { - expect(applyClaudeFolderTrust({ projects: [] }, ['/wt'])).toEqual({ kind: 'refuse' }) - }) - - it("keeps Claude's own fields on an existing untrusted entry", () => { - expect( - applyClaudeFolderTrust({ projects: { '/wt': { allowedTools: ['x'] } } }, ['/wt']) - ).toEqual({ - kind: 'changed', - config: { projects: { '/wt': { allowedTools: ['x'], hasTrustDialogAccepted: true } } } - }) - }) -}) - describe('grantClaudeFolderTrust', () => { it('merges the key and keeps every other field', async () => { const file = join(root, '.claude.json') @@ -149,14 +65,6 @@ describe('grantClaudeFolderTrust', () => { expect(statSync(file).mode & 0o777).toBe(0o600) }) - it('never creates a missing config file', async () => { - const file = join(root, '.claude.json') - await expect(grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] })).resolves.toBe( - 'missing-config' - ) - expect(existsSync(file)).toBe(false) - }) - it('leaves a corrupt file byte-for-byte untouched', async () => { const file = join(root, '.claude.json') writeFileSync(file, '{"oauthAccount": ', { mode: 0o600 }) @@ -195,16 +103,6 @@ describe('grantClaudeFolderTrust', () => { }) }) - it('leaves the parent directory mode alone', async () => { - const home = join(root, 'home') - mkdirSync(home, { mode: 0o755 }) - chmodSync(home, 0o755) - const file = join(home, '.claude.json') - writeConfig(file, {}) - await grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] }) - expect(statSync(home).mode & 0o777).toBe(0o755) - }) - it('grants every folder of a launch burst instead of losing some to its own lock', async () => { const file = join(root, '.claude.json') writeConfig(file, {}) @@ -237,79 +135,4 @@ describe('grantClaudeWorkspaceTrust', () => { } }) }) - - it('does not take the lock when the folder is already trusted', async () => { - const file = join(root, '.claude.json') - writeConfig(file, { projects: { [root]: { hasTrustDialogAccepted: true } } }) - mkdirSync(`${file}.lock`) - await expect( - grantClaudeWorkspaceTrust({ configFile: file, keyStyle: 'posix' }, root) - ).resolves.toBe('unchanged') - }) - - it('maps a host path to the path Claude sees and skips one it cannot map', async () => { - const file = join(root, '.claude.json') - writeConfig(file, {}) - await grantClaudeWorkspaceTrust( - { configFile: file, keyStyle: 'posix', toClaudePath: () => '/home/u/wt' }, - join(root, 'missing') - ) - expect(readConfig(file)).toEqual({ - projects: { '/home/u/wt': { hasTrustDialogAccepted: true } } - }) - await expect( - grantClaudeWorkspaceTrust( - { configFile: file, keyStyle: 'posix', toClaudePath: () => null }, - join(root, 'other') - ) - ).resolves.toBe('unchanged') - }) -}) - -describe('resolveLocalClaudeTrustConfig', () => { - const wslAuth: ClaudeRuntimeAuthPreparation = { - configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/u/.claude', - envPatch: {}, - provenance: 'wsl:Ubuntu:system' - } - - it('reads the final spawn env for a host launch', () => { - const target = resolveLocalClaudeTrustConfig({ - workspacePath: '/repo/wt', - env: { CLAUDE_CONFIG_DIR: '/cfg', HOME: '/home/u' }, - claudeAuth: null, - wslDistro: null - }) - expect(target?.configFile).toBe(join('/cfg', '.claude.json')) - }) - - it("never writes the Windows host's file for a WSL launch it cannot map to the guest", () => { - for (const args of [ - { claudeAuth: null, wslDistro: 'Ubuntu' }, - { claudeAuth: { ...wslAuth, wslLinuxConfigDir: null }, wslDistro: 'Ubuntu' }, - { claudeAuth: wslAuth, wslDistro: 'Ubuntu', workspacePath: 'C:\\repo\\wt' } - ]) { - expect( - resolveLocalClaudeTrustConfig({ - workspacePath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\wt', - env: { HOME: '/home/win' }, - ...args - }) - ).toBeNull() - } - }) - - it("targets the guest's own file and Linux keys for a WSL launch", () => { - const target = resolveLocalClaudeTrustConfig({ - workspacePath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\wt', - env: {}, - claudeAuth: wslAuth, - wslDistro: 'Ubuntu' - }) - expect(target?.keyStyle).toBe('posix') - expect(target?.toClaudePath?.('\\\\wsl.localhost\\Ubuntu\\home\\u\\wt')).toBe('/home/u/wt') - }) }) diff --git a/src/main/claude/claude-hook-event-install.test.ts b/src/main/claude/claude-hook-event-install.test.ts deleted file mode 100644 index 0e2dbe8982b..00000000000 --- a/src/main/claude/claude-hook-event-install.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { HooksConfig } from '../agent-hooks/installer-utils' -import fixture from './__fixtures__/claude-hook-event-enums.json' -import { readManagedHookDetectionResult } from '../agent-hooks/managed-hook-detection-commands' -import { - parseClaudeCliVersionOutput, - CLAUDE_HOOK_EVENT_FIRST_VERSIONS -} from './claude-hook-event-versions' -import { getClaudeManagedHookPlan } from './claude-managed-hook-events' -import { applyManagedHooks } from './hook-settings' - -const SCRIPT_FILE_NAME = 'claude-hook.sh' -const MANAGED_COMMAND = '/home/dev/.orca/agent-hooks/claude-hook.sh' -const managedHook = { type: 'command' as const, command: MANAGED_COMMAND } -const enums: Record = fixture.enums -const topLevelSettings: Record = fixture.topLevelSettings - -function install(config: HooksConfig, claudeVersion: string | undefined): HooksConfig { - return applyManagedHooks( - config, - () => managedHook, - SCRIPT_FILE_NAME, - getClaudeManagedHookPlan(claudeVersion) - ) -} - -function managedEvents(config: HooksConfig): string[] { - return Object.entries(config.hooks ?? {}) - .filter(([, definitions]) => - (definitions ?? []).some((definition) => - (definition.hooks ?? []).some((hook) => hook.command === MANAGED_COMMAND) - ) - ) - .map(([event]) => event) - .sort() -} - -const userHook = (event: string) => ({ - hooks: [{ type: 'command' as const, command: `echo user-${event}` }] -}) - -function userOwnedSettings(): HooksConfig { - const events = [...Object.keys(CLAUDE_HOOK_EVENT_FIRST_VERSIONS), 'Notification'] - return { - env: { CLAUDE_CODE_USE_BEDROCK: '1' }, - permissions: { allow: ['Bash(git status)'] }, - hooks: Object.fromEntries(events.map((event) => [event, [userHook(event)]])) - } -} - -function expectUserSettingsKept(config: HooksConfig): void { - expect(config.env).toEqual({ CLAUDE_CODE_USE_BEDROCK: '1' }) - expect(config.permissions).toEqual({ allow: ['Bash(git status)'] }) - for (const [event, definitions] of Object.entries(userOwnedSettings().hooks ?? {})) { - expect(config.hooks?.[event], event).toContainEqual(definitions?.[0]) - } -} - -describe('Claude managed hook events by resolved version', () => { - it.each([true, false])( - 'keeps a managed hook async=%s across reinstall and migration', - (async) => { - const userAsyncHook = { type: 'command' as const, command: 'echo user', async: !async } - const oldManagedHook = { - type: 'command' as const, - command: '/bin/sh', - args: ['/old/.orca/agent-hooks/claude-hook.sh'], - async - } - const written = install( - { - hooks: { - PreToolUse: [{ hooks: [userAsyncHook, oldManagedHook] }], - PostToolUse: [{ hooks: [userAsyncHook] }] - } - }, - '2.1.32' - ) - expect(written.hooks?.PreToolUse).toEqual([ - { hooks: [userAsyncHook] }, - { matcher: '*', hooks: [{ ...managedHook, async }] } - ]) - expect(written.hooks?.PostToolUse).toEqual([ - { hooks: [userAsyncHook] }, - { matcher: '*', hooks: [managedHook] } - ]) - expect(install(written, '2.1.32')).toEqual(written) - } - ) - - it.each(Object.keys(enums))('writes only events Claude %s knows', (version) => { - const written = install({ hooks: {} }, version) - const unknown = Object.keys(written.hooks ?? {}).filter( - (event) => !enums[version].includes(event) - ) - expect(unknown).toEqual([]) - }) - - it.each(Object.keys(topLevelSettings))( - 'writes statusLine only if Claude %s knows it', - (version) => { - const plan = getClaudeManagedHookPlan(version) - expect(plan.statusLine === 'install').toBe( - topLevelSettings[version].keys.includes('statusLine') - ) - } - ) - - it('omits every event newer than an old Claude', () => { - expect(managedEvents(install({ hooks: {} }, '2.1.32 (Claude Code)'))).toEqual( - [ - 'PermissionRequest', - 'PostToolUse', - 'PostToolUseFailure', - 'PreToolUse', - 'SessionStart', - 'Stop', - 'SubagentStart', - 'SubagentStop', - 'UserPromptSubmit' - ].sort() - ) - }) - - it.each([undefined, 'unknown'])( - 'writes only the core lifecycle events when the version is %s', - (version) => { - expect(managedEvents(install({ hooks: {} }, version))).toEqual( - [ - 'PostToolUse', - 'PreToolUse', - 'SessionStart', - 'Stop', - 'SubagentStop', - 'UserPromptSubmit' - ].sort() - ) - } - ) - - it.each([undefined, '11.17.0', '2.1.310\nwrapper for (Claude Code)'])( - 'leaves Orca entries a version-aware install wrote untouched when the version is %s', - (output) => { - const version = parseClaudeCliVersionOutput(output) ?? undefined - const current = install(userOwnedSettings(), '2.1.261') - expect(JSON.stringify(install(current, version))).toBe(JSON.stringify(current)) - - const staleHook = { - type: 'command' as const, - command: '/old/.orca/agent-hooks/claude-hook.sh' - } - const stale = { hooks: { StopFailure: [{ hooks: [staleHook] }] } } - const written = install(stale, version) - expect(JSON.stringify(written.hooks?.StopFailure)).toBe( - JSON.stringify(stale.hooks.StopFailure) - ) - expect(managedEvents(written)).toEqual( - [ - 'PostToolUse', - 'PreToolUse', - 'SessionStart', - 'Stop', - 'SubagentStop', - 'UserPromptSubmit' - ].sort() - ) - } - ) - - it('adds the newer events once the resolved Claude is upgraded', () => { - const old = install({ hooks: {} }, '2.1.77') - expect(old.hooks?.StopFailure).toBeUndefined() - - const upgraded = install(old, '2.1.78') - - expect(managedEvents(upgraded)).toContain('StopFailure') - expect(managedEvents(upgraded)).toContain('PostCompact') - expect(upgraded.hooks?.StopFailure).toEqual([{ hooks: [managedHook] }]) - }) - - it('removes only Orca entries for events a downgraded Claude does not know', () => { - const current = install({ hooks: {} }, '2.1.261') - expect(managedEvents(current)).toContain('SessionEnd') - - const { claudeVersion } = readManagedHookDetectionResult({ - agents: ['claude'], - versions: { claude: 'launcher 11.17.0\n2.1.32 (Claude Code)' } - }) - const downgraded = install(current, claudeVersion ?? undefined) - - for (const event of [ - 'StopFailure', - 'PostCompact', - 'TeammateIdle', - 'SessionEnd', - 'Elicitation', - 'ElicitationResult' - ]) { - expect(downgraded.hooks?.[event], event).toBeUndefined() - } - }) - - it('gates a known Claude older than the unresolved-version set by its own enum', () => { - expect(managedEvents(install({ hooks: {} }, '1.0.52'))).toEqual( - ['PostToolUse', 'PreToolUse', 'Stop', 'SubagentStop'].sort() - ) - const downgraded = install(install({ hooks: {} }, '2.1.261'), '1.0.22') - expect(managedEvents(downgraded)).toEqual([]) - }) - - it.each(['1.0.22', '1.0.52', '1.0.81', '2.1.32', '2.1.77', '2.1.78', '2.1.261', undefined])( - 'keeps every user-written entry and setting when installing for %s', - (version) => { - const fresh = install(userOwnedSettings(), version) - expectUserSettingsKept(fresh) - expectUserSettingsKept(install(install(userOwnedSettings(), '2.1.261'), version)) - } - ) - - it('leaves a user value it cannot parse under an unknown event untouched', () => { - const settings: HooksConfig = JSON.parse('{"hooks":{"StopFailure":[],"PostCompact":"mine"}}') - const written = install(settings, '2.1.32') - expect(written.hooks?.StopFailure).toEqual([]) - expect(written.hooks?.PostCompact).toBe('mine') - }) - - it('keeps SessionEnd behind its measured 2.1.261 floor', () => { - expect(install({ hooks: {} }, '2.1.260').hooks?.SessionEnd).toBeUndefined() - expect(install({ hooks: {} }, '2.1.261').hooks?.SessionEnd).toEqual([{ hooks: [managedHook] }]) - }) -}) diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index a7188dcf386..eccf3fcc892 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -1,26 +1,10 @@ -import { agentModelCatalogEntryWithSuccess } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' -import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-catalog/agent-model-catalog-unavailable' import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' +import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { PROVIDER_STDIN_END_GRACE_MS } from '../provider-process/provider-process-supervisor' import { createClaudeModelCatalogProbe } from './claude-model-catalog-probe' -import { createClaudeCommandCatalogProbe } from './claude-command-catalog-probe' -import signinCapture from './__fixtures__/catalog-signin.json' -import { discoveredModel } from '../../shared/agent-session/options/structured-agent-session-discovered-model' -import { resolveClaudeStructuredInvocation } from './structured/launch/claude-structured-launch-resolution' import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' -import { - AgentModelCatalogProbeError, - type runAgentModelCatalogListing -} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' -import { - CLAUDE_MODEL_LIST_ARGS, - CLAUDE_MODEL_LIST_STDIN -} from '../../shared/claude-model-list-probe' - -type ListingCall = Parameters const AUTH_POLICY = { account: 'system' } as const @@ -46,141 +30,7 @@ function probeDeps(command = `"${process.execPath}"`): { } } -const LISTED = `${JSON.stringify({ - type: 'control_response', - response: { - subtype: 'success', - request_id: 'orca-model-discovery', - response: { - models: [ - { - value: 'sonnet', - displayName: 'Sonnet', - supportsEffort: true, - supportedEffortLevels: ['low'] - } - ] - } - } -})}\n` - describe('claude model catalog probe', () => { - it.each([`"${process.execPath}"`, '', `"${process.execPath}" run -- claude`])( - 'lists with the session executable and env for Command %j', - async (command) => { - const deps = probeDeps(command) - const calls: ListingCall[] = [] - const probe = createClaudeModelCatalogProbe({ - ...deps, - runListing: async (...call) => { - calls.push(call) - return LISTED - } - }) - const success = await probe(HOME('/homes/account-a')) - expect(success.origin).toBe('probe') - expect(success.models).toEqual([ - { - id: 'sonnet', - label: 'Sonnet', - isDefault: false, - efforts: [{ value: 'low', label: 'Low' }] - } - ]) - const invocation = await resolveClaudeStructuredInvocation(deps, (env) => ({ - ...env, - CLAUDE_CONFIG_DIR: '/homes/account-a' - })) - expect(calls).toHaveLength(1) - const [launch, options] = calls[0]! - expect(launch).toEqual({ - command: invocation.command, - args: [ - ...invocation.prefixArgs, - ...CLAUDE_MODEL_LIST_ARGS, - '--setting-sources', - 'user,project,local' - ], - cwd: process.cwd(), - stdin: expect.stringContaining(CLAUDE_MODEL_LIST_STDIN) - }) - expect(options.inheritedEnv).toEqual(invocation.env) - } - ) - - it('keeps the CLI default and account Fast and per-row auto support', async () => { - const listing = `${JSON.stringify({ - type: 'control_response', - response: { - subtype: 'success', - request_id: 'orca-model-discovery', - response: { - models: [ - { value: 'default', resolvedModel: 'claude-opus-5-5[1m]', supportsAutoMode: true }, - { - value: 'opus[1m]', - resolvedModel: 'claude-opus-5-5[1m]', - supportsAutoMode: true, - supportsFastMode: true - }, - { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', supportsAutoMode: null } - ] - } - } - })}\n` - const probe = createClaudeModelCatalogProbe({ ...probeDeps(), runListing: async () => listing }) - const { models, fastModeSupport } = await probe(HOME('/homes/account-a')) - expect(fastModeSupport).toEqual({ supported: true }) - expect(models[0]?.supportsFastMode).toBe(true) - expect( - models[0] && discoveredModel(models[0], fastModeSupport?.supported === true, 'claude').options - ).toContainEqual(expect.objectContaining({ id: 'fastMode' })) - expect( - models.map(({ id, isDefault, supportsAutoMode }) => ({ id, isDefault, supportsAutoMode })) - ).toEqual([ - { id: 'claude-opus-5-5[1m]', isDefault: true, supportsAutoMode: true }, - { id: 'claude-haiku-4-5-20251001', isDefault: false, supportsAutoMode: undefined }, - { id: 'opus[1m]', isDefault: false, supportsAutoMode: true }, - { id: 'haiku', isDefault: false, supportsAutoMode: undefined } - ]) - }) - - it('pins no CLAUDE_CONFIG_DIR for the CLI default home, exactly as a session spawn does', async () => { - const envs: (NodeJS.ProcessEnv | undefined)[] = [] - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - runListing: async (_launch, options) => { - envs.push(options.inheritedEnv) - return LISTED - } - }) - await probe(HOME(join(homedir(), '.claude'))) - await probe(HOME('/homes/account-a')) - expect(envs[0]).not.toHaveProperty('CLAUDE_CONFIG_DIR') - expect(envs[1]).toMatchObject({ CLAUDE_CONFIG_DIR: '/homes/account-a' }) - }) - - it('refuses an answer that names no model, as an older CLI gives, rather than store it', async () => { - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - runListing: async () => - `${JSON.stringify({ type: 'control_response', response: { subtype: 'error' } })}\n` - }) - await expect(probe(HOME('/homes/a'))).rejects.toThrow(/listed no models/) - }) - - it.each(['launcher $SECRET claude', 'FOO=1 claude', '/missing/claude', './claude'])( - 'lists nothing, and never probes the stock CLI, for Command %s', - async (command) => { - const runListing = vi.fn() - const probe = createClaudeModelCatalogProbe({ ...probeDeps(command), runListing }) - await expect(probe(HOME('/homes/a'))).rejects.toMatchObject({ - reason: 'agentCommandNotRunnable' - }) - expect(runListing).not.toHaveBeenCalled() - } - ) - it.runIf(process.platform !== 'win32')( 'lists through a supervised one-shot that answers after its input ends', async () => { @@ -223,31 +73,6 @@ process.stdin.on('end', () => setTimeout(() => { }) describe('Claude catalog availability', () => { - it('does not turn a generic listing failure into unavailable', async () => { - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - runListing: async () => { - throw new AgentModelCatalogProbeError('claude did not list models', 'timeout') - } - }) - await expect(probe(HOME('/homes/a'))).rejects.not.toBeInstanceOf( - AgentModelCatalogUnavailableError - ) - }) - - it.each([false, true])('lists with no sign-in verdict, managed=%s', async (managed) => { - const runListing = vi.fn(async () => LISTED) - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - resolveAuthPolicy: () => ({ account: managed ? ('managed' as const) : ('system' as const) }), - runListing - }) - const result = await probe(HOME('/homes/a')) - expect(result).toMatchObject({ models: [{ id: 'sonnet' }] }) - expect(result).not.toHaveProperty('unavailable') - expect(runListing).toHaveBeenCalledTimes(1) - }) - it('says the CLI is missing when the resolved executable is not there', async () => { const folder = mkdtempSync(join(tmpdir(), 'orca-claude-missing-')) try { @@ -264,188 +89,3 @@ describe('Claude catalog availability', () => { } }) }) - -it.each([null, 0, 1])('enterprise retry uses the actual exit code %s', async (exitCode) => { - const stderr = 'You cannot use --strict-mcp-config when an enterprise MCP config is present' - const runListing = vi.fn(async () => LISTED) - runListing.mockRejectedValueOnce( - new AgentModelCatalogProbeError(stderr, 'exit', false, { exitCode, stdout: '', stderr }) - ) - const result = createClaudeModelCatalogProbe({ ...probeDeps(), runListing })(HOME('/homes/a')) - await (exitCode === 1 - ? expect(result).resolves.toMatchObject({ models: [{ id: 'sonnet' }] }) - : expect(result).rejects.toMatchObject({ failedExit: { exitCode } })) - expect(runListing).toHaveBeenCalledTimes(exitCode === 1 ? 2 : 1) -}) - -it.each(['high', 'low'])( - 'discovers configured %s without chat picks and preserves literal settings', - async (effort) => { - const calls: ListingCall[] = [] - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - resolveLaunchArgs: () => [ - '--model', - 'sonnet', - '--effort', - effort, - '--settings', - '/literal/settings.json' - ], - runListing: async (...call) => { - calls.push(call) - return `${LISTED}${JSON.stringify({ - type: 'control_response', - response: { - subtype: 'success', - request_id: 'orca-model-settings', - response: { applied: { model: 'sonnet', effort } } - } - })}\n` - } - }) - const resolved = await probe.resolve?.(HOME('/account'), '/project') - const success = await resolved!.run() - expect(success).toMatchObject({ configured: { model: 'sonnet', effort } }) - expect( - agentModelCatalogEntryWithSuccess( - undefined, - { agent: 'claude', fingerprint: 'fp' }, - success, - 'discovery', - 1 - )?.models.find((row) => row.id === 'sonnet')?.declaredDefaultEffort - ).toBe(effort === 'low' ? 'low' : null) - expect(calls[0]?.[0].args).toEqual( - expect.arrayContaining([ - '--model', - 'sonnet', - '--effort', - effort, - '--settings', - '/literal/settings.json' - ]) - ) - expect(calls[0]?.[0].args.filter((arg) => arg === '--settings')).toHaveLength(1) - expect( - calls[0]?.[0].stdin - ?.trim() - .split('\n') - .map((line) => JSON.parse(line)) - ).toHaveLength(3) - } -) - -it('qualifies only a correlated valid settings observation', async () => { - for (const [applied, requestId, complete] of [ - [{}, 'orca-model-settings', true], - [[], 'orca-model-settings', false], - [{ model: 42 }, 'orca-model-settings', false], - [{}, 'other-request', false], - [undefined, 'orca-model-settings', false] - ] as const) { - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - runListing: async () => - LISTED + - JSON.stringify({ - type: 'control_response', - response: { - subtype: 'success', - request_id: requestId, - response: applied === undefined ? {} : { applied } - } - }) - }) - expect((await probe(HOME('/account'))).complete).toBe(complete) - } -}) - -function controlReply(requestId: string, response: unknown): string { - return `${JSON.stringify({ type: 'control_response', response: { subtype: 'success', request_id: requestId, response } })}\n` -} - -it.each(['system', 'managed'] as const)( - 'keeps recorded models with the signed-out %s account advisory', - async (account) => { - const runListing = vi.fn( - async () => - controlReply('orca-model-discovery', signinCapture.models) + - controlReply('orca-model-settings', signinCapture.settings) + - controlReply('orca-signin-discovery', { account: signinCapture.accounts.signedOut }) - ) - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - resolveAuthPolicy: () => ({ account }), - runListing - }) - const success = await probe(HOME('/account')) - expect(success).toMatchObject({ unavailable: { reason: 'notSignedIn', account } }) - expect(success.models.length).toBeGreaterThan(0) - const [launch, options] = runListing.mock.calls[0] ?? [] - expect( - launch?.stdin - ?.trim() - .split('\n') - .map((line: string) => JSON.parse(line).request.subtype) - ).toEqual(['list_models', 'get_settings', 'initialize']) - expect( - options?.partialOutput?.ready(controlReply('orca-model-discovery', signinCapture.models)) - ).toBe(false) - expect( - options?.partialOutput?.ready( - controlReply('orca-model-discovery', signinCapture.models) + - controlReply('orca-model-settings', signinCapture.settings) - ) - ).toBe(true) - } -) - -it('requires definitive first-party account facts, preserving inventory when auth is unknown', async () => { - const cases = [ - { account: signinCapture.accounts.apiKey }, - { account: signinCapture.accounts.bearer }, - { account: signinCapture.accounts.bedrock }, - { account: { tokenSource: 'none', email: 'user@example.test' } }, - { account: { tokenSource: 'none', subscriptionType: 'max' } }, - { account: {} }, - { account: undefined }, - { account: signinCapture.accounts.signedOut, settings: signinCapture.endpointSettings }, - { account: signinCapture.accounts.signedOut, endpoint: 'https://gateway.example.test' }, - { account: signinCapture.accounts.signedOut, settings: {} } - ] - for (const { account, settings = signinCapture.settings, endpoint } of cases) { - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - resolveEnv: (): Record => (endpoint ? { ANTHROPIC_BASE_URL: endpoint } : {}), - runListing: async () => - controlReply('orca-model-discovery', signinCapture.models) + - controlReply('orca-model-settings', settings) + - (account === undefined ? '' : controlReply('orca-signin-discovery', { account })) - }) - const success = await probe(HOME('/account')) - expect(success.unavailable).toBeUndefined() - expect(success.models.length).toBeGreaterThan(0) - } -}) - -it('reads slash commands as main did: past error, diagnostic and oversized lines, with or without type', async () => { - const { error, initialize } = signinCapture.commandFrames - const nested = (depth: number): unknown => (depth === 0 ? {} : { response: nested(depth - 1) }) - const oversized = JSON.stringify({ - ...initialize, - response: { ...initialize.response, response: nested(20) } - }) - const list = (lines: string[]) => - createClaudeCommandCatalogProbe({ - ...probeDeps(), - runListing: async () => `${lines.join('\n')}\n` - })({ cwd: '/project', accountHomePath: '/homes/account-a' }) - const rejected = [JSON.stringify(error), 'orca-command-discovery request_id not ready', oversized] - await expect(list(rejected)).rejects.toThrow('claude listed no commands') - const { type: _type, ...untyped } = initialize - for (const frame of [initialize, untyped]) { - const commands = await list([...rejected, JSON.stringify(frame)]) - expect(commands.map((command) => command.name)).toEqual(['deep-research', 'design']) - } -}) diff --git a/src/main/claude/claude-plan-approval-mode.test.ts b/src/main/claude/claude-plan-approval-mode.test.ts index 89444134089..728e94c2d94 100644 --- a/src/main/claude/claude-plan-approval-mode.test.ts +++ b/src/main/claude/claude-plan-approval-mode.test.ts @@ -1,13 +1,4 @@ -import type { - PermissionMode, - PermissionResult, - Query, - SDKMessage -} from '@anthropic-ai/claude-agent-sdk' import { describe, expect, it, vi } from 'vitest' -import { createClaudeControlSurface } from './claude-agent-sdk-control-requests' -import { claudePlanApprovalMode, leaveClaudePlanModeOnApproval } from './claude-plan-approval-mode' -import type { ClaudePendingPrompt } from './claude-prompt-registry' import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { PROVIDER_SESSION_ID, @@ -20,64 +11,6 @@ import { } from './structured/claude-structured-session-test-support' import { claudeStructuredPermissionOptions } from './structured/launch/claude-structured-launch-resolution' -const PLAN_PROMPT: Pick = { toolName: 'ExitPlanMode' } -const ALLOW: PermissionResult = { behavior: 'allow', updatedInput: {}, toolUseID: 'tool-plan' } - -type PlanSession = Parameters[0] - -function planSession( - setPermissionMode: (mode: string) => Promise, - overrides: Partial = {} -): PlanSession { - return { - connection: { setPermissionMode }, - reportedOptions: { reportedPermissionMode: 'plan' }, - launchIntent: { permissionMode: 'acceptEdits' }, - ...overrides - } -} - -function recordingQuery(setPermissionMode: Query['setPermissionMode']): Query { - function unusedControl(): never { - throw new Error('unexpected query control') - } - const messages = (async function* (): AsyncGenerator { - yield* [] - })() - return Object.assign(messages, { - setPermissionMode, - interrupt: unusedControl, - setMcpPermissionModeOverride: unusedControl, - setModel: unusedControl, - setMaxThinkingTokens: unusedControl, - applyFlagSettings: unusedControl, - updateSettings: unusedControl, - initializationResult: unusedControl, - reinitialize: unusedControl, - supportedCommands: unusedControl, - supportedModels: unusedControl, - supportedAgents: unusedControl, - mcpServerStatus: unusedControl, - getContextUsage: unusedControl, - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: unusedControl, - readFile: unusedControl, - reloadPlugins: unusedControl, - reloadSkills: unusedControl, - reloadOutputStyles: unusedControl, - accountInfo: unusedControl, - rewindFiles: unusedControl, - seedReadState: unusedControl, - reconnectMcpServer: unusedControl, - toggleMcpServer: unusedControl, - readMcpResource: unusedControl, - setMcpServers: unusedControl, - streamInput: unusedControl, - stopTask: unusedControl, - backgroundTasks: unusedControl, - close: unusedControl - }) -} - async function pendingPlanApproval(input: Record, initPermissionMode?: string) { const claude = fakeClaude({ replayUuid: 'turn-1', initPermissionMode }) const adapter = await acquired(claude) @@ -102,94 +35,6 @@ async function pendingPlanApproval(input: Record, initPermissio } describe('leaving Claude plan mode on an approved plan', () => { - it.each<[Parameters[0], PermissionMode]>([ - [{ launchIntent: { permissionMode: 'acceptEdits' } }, 'acceptEdits'], - [{ launchIntent: { permissionMode: 'bypassPermissions' } }, 'bypassPermissions'], - [{ launchIntent: {}, firstRootInit: { permissionMode: 'auto' } }, 'auto'], - [{ launchIntent: {}, firstRootInit: { permissionMode: 'dontAsk' } }, 'dontAsk'], - [{ launchIntent: {}, firstRootInit: { permissionMode: 'unknown' } }, 'default'], - // The user's own CLI settings started in plan: approving still lets Claude edit, asking each time. - [{ launchIntent: {}, firstRootInit: { permissionMode: 'plan' } }, 'default'], - [{}, 'default'] - ])('returns to the chat’s own mode (%j)', (session, mode) => { - expect(claudePlanApprovalMode(session)).toBe(mode) - }) - - it('changes nothing for Keep planning, other approvals, or a chat already out of plan', () => { - const setPermissionMode = vi.fn(async () => {}) - const deny: PermissionResult = { behavior: 'deny', message: 'keep planning', toolUseID: 't' } - leaveClaudePlanModeOnApproval(planSession(setPermissionMode), PLAN_PROMPT, deny) - leaveClaudePlanModeOnApproval(planSession(setPermissionMode), { toolName: 'Bash' }, ALLOW) - leaveClaudePlanModeOnApproval( - planSession(setPermissionMode, { reportedOptions: { reportedPermissionMode: 'ask' } }), - PLAN_PROMPT, - ALLOW - ) - expect(setPermissionMode).not.toHaveBeenCalled() - }) - - it.each([ - { label: 'plan text', input: { plan: '# Add multiply' }, mode: 'default', failure: 'none' }, - { label: 'empty input', input: {}, mode: 'default', failure: 'none' }, - { label: 'own dontAsk', input: {}, mode: 'dontAsk', failure: 'none' }, - { label: 'rejected mode request', input: {}, mode: 'default', failure: 'async' }, - { label: 'closed connection', input: {}, mode: 'default', failure: 'sync' } - ])( - 'settles the real plan callback after its mode request: $label', - async ({ input, mode, failure }) => { - const { adapter, connection, answered } = await pendingPlanApproval(input, mode) - const order: string[] = [] - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const controls = createClaudeControlSurface( - recordingQuery(async (requestedMode) => { - order.push(`set_permission_mode:${requestedMode}`) - if (failure === 'async') { - throw new Error('mode rejected') - } - }) - ) - const setMode = vi.spyOn(connection, 'setPermissionMode').mockImplementation( - failure === 'sync' - ? (requestedMode) => { - order.push(`set_permission_mode:${requestedMode}`) - throw new Error('closed') - } - : controls.setPermissionMode - ) - const callback = answered.promise.then((result) => { - order.push('control_response') - return result - }) - try { - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-plan', - kind: 'approval', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit: async () => { - expect(setMode).not.toHaveBeenCalled() - expect(answered.settled()).toBe(false) - } - }) - - await expect(callback).resolves.toMatchObject({ - behavior: 'allow', - updatedInput: input, - toolUseID: 'tool-plan' - }) - expect(order).toEqual([`set_permission_mode:${mode}`, 'control_response']) - expect(setMode).toHaveBeenCalledOnce() - if (failure !== 'none') { - await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce()) - } - } finally { - warn.mockRestore() - setMode.mockRestore() - } - } - ) - it.each(['failed commit', 'lost ownership'])( 'does not change mode when approval has %s', async (failure) => { diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts deleted file mode 100644 index 29e7f55fb45..00000000000 --- a/src/main/claude/claude-slash-command-catalog.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { ClaudeSlashCommandCatalog, readClaudeSlashCommands } from './claude-slash-command-catalog' - -function init(overrides: Record = {}): Record { - return { - type: 'system', - subtype: 'init', - session_id: 'provider-1', - slash_commands: ['clear', 'ref-oss', 'doctor', 'opsx:apply'], - terminal_slash_commands: ['doctor'], - skills: ['ref-oss', 'doctor'], - ...overrides - } -} - -describe('claude slash command catalog', () => { - it('tags reported skills and keeps the commands Claude marks terminal-only', () => { - expect(readClaudeSlashCommands(init())).toEqual([ - { name: 'clear', kind: 'command' }, - { name: 'ref-oss', kind: 'skill' }, - { name: 'doctor', kind: 'skill' }, - { name: 'opsx:apply', kind: 'command' } - ]) - }) - - it('rejects blank, whitespace-carrying and duplicate names', () => { - expect( - readClaudeSlashCommands( - init({ slash_commands: ['clear', ' ', 'two words', 'clear'], skills: [] }) - ) - ).toEqual([{ name: 'clear', kind: 'command' }]) - }) - - it('seeds from the init frame that proved the session', () => { - expect(new ClaudeSlashCommandCatalog(init()).commands).toHaveLength(4) - expect(new ClaudeSlashCommandCatalog().commands).toBeUndefined() - expect( - new ClaudeSlashCommandCatalog({ type: 'system', subtype: 'init' }).commands - ).toBeUndefined() - }) - - it('replaces the catalog on commands_changed and reports only real changes', () => { - const catalog = new ClaudeSlashCommandCatalog(init()) - expect(catalog.observe(init())).toBe(false) - expect(catalog.observe({ type: 'assistant', slash_commands: ['other'] })).toBe(false) - expect( - catalog.observe({ - type: 'system', - subtype: 'commands_changed', - slash_commands: ['clear', 'brand-new'], - skills: ['brand-new'] - }) - ).toBe(true) - expect(catalog.commands).toEqual([ - { name: 'clear', kind: 'command' }, - { name: 'brand-new', kind: 'skill' } - ]) - }) - - it('keeps a project skill a skill after the agent cds out of the launch folder', () => { - // Captured CLI behavior: cd changes skills while slash_commands retains launch-folder project skills. - const catalog = new ClaudeSlashCommandCatalog( - init({ cwd: '/repo', slash_commands: ['qa-project'], skills: ['qa-project'] }) - ) - catalog.observe( - init({ - cwd: '/scratch', - slash_commands: ['qa-project', 'found-here'], - skills: ['found-here'] - }) - ) - expect(catalog.commands).toEqual([ - { name: 'qa-project', kind: 'skill' }, - { name: 'found-here', kind: 'skill' } - ]) - }) - - it('notices a name that only changed kind', () => { - const catalog = new ClaudeSlashCommandCatalog( - init({ slash_commands: ['review'], skills: [], terminal_slash_commands: [] }) - ) - expect( - catalog.observe({ - type: 'system', - subtype: 'commands_changed', - slash_commands: ['review'], - skills: ['review'] - }) - ).toBe(true) - expect(catalog.commands).toEqual([{ name: 'review', kind: 'skill' }]) - }) -}) - -it('accepts descriptor reloads, removing old skills and keeping terminal-only commands', () => { - const catalog = new ClaudeSlashCommandCatalog(init()) - const reload = { - type: 'system', - subtype: 'commands_changed', - commands: [ - { name: 'clear', description: 'Clear', argumentHint: '' }, - { name: 'new-skill', description: 'New', argumentHint: '' }, - { name: 'doctor', description: 'Terminal', argumentHint: '' } - ] - } - expect(catalog.observe(reload)).toBe(true) - expect(catalog.commands).toEqual([ - { name: 'clear', kind: 'command', description: 'Clear' }, - { name: 'new-skill', kind: 'skill', description: 'New' }, - { name: 'doctor', kind: 'skill', description: 'Terminal' } - ]) - expect(catalog.observe(reload)).toBe(false) - expect(catalog.observe({ ...reload, commands: [] })).toBe(true) - expect(catalog.commands).toEqual([]) -}) - -it('lets stream init refine a control seed and preserves kinds across descriptor reloads', () => { - const seed = { commands: [{ name: 'clear' }, { name: 'project-check' }] } - const catalog = new ClaudeSlashCommandCatalog(undefined, seed) - expect(catalog.commands).toEqual([ - { name: 'clear', kind: 'command', kindUnspecified: true }, - { name: 'project-check', kind: 'command', kindUnspecified: true } - ]) - expect(catalog.observe({ type: 'system', subtype: 'commands_changed', ...seed })).toBe(false) - const fullInit = init({ slash_commands: ['clear', 'project-check'], skills: ['project-check'] }) - expect(catalog.observe(fullInit)).toBe(true) - expect(catalog.commands).toEqual([ - { name: 'clear', kind: 'command' }, - { name: 'project-check', kind: 'skill' } - ]) - expect(catalog.observe({ type: 'system', subtype: 'commands_changed', ...seed })).toBe(false) - expect(new ClaudeSlashCommandCatalog(fullInit, seed).commands).toEqual(catalog.commands) - expect(new ClaudeSlashCommandCatalog(init({ slash_commands: [] }), seed).commands).toEqual([]) -}) - -it('distinguishes missing or malformed control catalogs from authoritative empty ones', () => { - for (const initialization of [undefined, null, {}, { commands: null }, { commands: 'bad' }]) { - expect(new ClaudeSlashCommandCatalog(undefined, initialization).commands).toBeUndefined() - } - expect(new ClaudeSlashCommandCatalog(undefined, { commands: [] }).commands).toEqual([]) - expect( - new ClaudeSlashCommandCatalog(undefined, { - commands: [null, {}, { name: ' ' }, { name: 'two words' }, { name: 'ok' }, { name: 'ok' }] - }).commands - ).toEqual([{ name: 'ok', kind: 'command', kindUnspecified: true }]) -}) - -it('publishes classification becoming authoritative even when the name and kind stay unchanged', () => { - const catalog = new ClaudeSlashCommandCatalog(undefined, { commands: [{ name: 'clear' }] }) - expect(catalog.observe(init({ slash_commands: ['clear'], skills: [] }))).toBe(true) - expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) -}) - -it('keeps the description and argument hint a descriptor report authored', () => { - const catalog = new ClaudeSlashCommandCatalog(undefined, { - commands: [ - { name: 'goal', description: 'Set or view the goal', argumentHint: '' }, - { name: 'quiet', description: '', argumentHint: '' } - ] - }) - expect(catalog.commands).toEqual([ - { - name: 'goal', - kind: 'command', - kindUnspecified: true, - description: 'Set or view the goal', - argumentHint: '' - }, - { name: 'quiet', kind: 'command', kindUnspecified: true } - ]) -}) - -it('bounds the row text a provider can put in the picker', () => { - const catalog = new ClaudeSlashCommandCatalog(undefined, { - commands: [ - { name: 'long', description: 'x'.repeat(201), argumentHint: 'y'.repeat(101) }, - { name: 'wrong-type', description: 42, argumentHint: { text: 'no' } }, - { name: 'blank', description: ' ' }, - { name: 'long-whitespace', description: `Visible${' '.repeat(201)}` }, - { name: 'wrapped', description: 'first line\n second line' } - ] - }) - expect(catalog.commands).toEqual([ - { name: 'long', kind: 'command', kindUnspecified: true }, - { name: 'wrong-type', kind: 'command', kindUnspecified: true }, - { name: 'blank', kind: 'command', kindUnspecified: true }, - { name: 'long-whitespace', kind: 'command', kindUnspecified: true }, - { - name: 'wrapped', - kind: 'command', - kindUnspecified: true, - description: 'first line second line' - } - ]) -}) - -it('does not let malformed descriptor names consume the command detail budget', () => { - const catalog = new ClaudeSlashCommandCatalog(undefined, { - commands: [ - ...Array.from({ length: 512 }, (_, index) => ({ - name: `invalid name ${index}`, - description: 'Rejected with its name' - })), - { name: 'goal', description: 'Set or view the goal', argumentHint: '' } - ] - }) - expect(catalog.commands).toEqual([ - { - name: 'goal', - kind: 'command', - kindUnspecified: true, - description: 'Set or view the goal', - argumentHint: '' - } - ]) -}) - -it('combines non-empty fields from duplicate descriptors without discarding earlier text', () => { - const catalog = new ClaudeSlashCommandCatalog(undefined, { - commands: [ - { name: 'goal', description: 'Set or view the goal' }, - { name: 'goal', argumentHint: '' } - ] - }) - expect(catalog.commands).toEqual([ - { - name: 'goal', - kind: 'command', - kindUnspecified: true, - description: 'Set or view the goal', - argumentHint: '' - } - ]) -}) - -it('carries descriptor text across the name-only stream init that classifies it', () => { - const catalog = new ClaudeSlashCommandCatalog(undefined, { - commands: [ - { name: 'clear', description: 'Clear conversation' }, - { name: 'ref-oss', description: 'A skill' } - ] - }) - expect(catalog.observe(init({ slash_commands: ['clear', 'ref-oss'], skills: ['ref-oss'] }))).toBe( - true - ) - expect(catalog.commands).toEqual([ - { name: 'clear', kind: 'command', description: 'Clear conversation' }, - { name: 'ref-oss', kind: 'skill', description: 'A skill' } - ]) -}) - -it('reports a description-only change and lets a later report drop the text', () => { - const catalog = new ClaudeSlashCommandCatalog(init({ slash_commands: ['clear'], skills: [] })) - expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) - const changed = { - type: 'system', - subtype: 'commands_changed', - commands: [{ name: 'clear', description: 'Clear conversation history' }] - } - expect(catalog.observe(changed)).toBe(true) - expect(catalog.commands).toEqual([ - { name: 'clear', kind: 'command', description: 'Clear conversation history' } - ]) - expect(catalog.observe(changed)).toBe(false) - expect(catalog.observe({ ...changed, commands: [{ name: 'clear' }] })).toBe(true) - expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) -}) - -// Claude 2.1.310 initialize rows; a user skill named goal replaces the built-in row, unmarked. -const BUILTIN_GOAL = { - name: 'goal', - description: 'Set a goal — keep working until the condition is met', - argumentHint: '', - builtin: true -} -const BUILTIN_CLEAR = { - name: 'clear', - description: 'Start a new session with empty context', - argumentHint: '[name]', - aliases: ['reset', 'new'], - builtin: true -} -const SKILL_GOAL = { - name: 'goal', - description: 'Project goal tracker skill (user)', - argumentHint: '' -} - -it('marks only the rows Claude reports as its own built-ins', () => { - const marked = (commands: unknown[]) => - new ClaudeSlashCommandCatalog(undefined, { commands }).commands?.map( - ({ name, builtin }) => `${name}:${builtin === true}` - ) - expect(marked([BUILTIN_CLEAR, BUILTIN_GOAL])).toEqual(['clear:true', 'goal:true']) - expect(marked([SKILL_GOAL, BUILTIN_CLEAR])).toEqual(['goal:false', 'clear:true']) - expect(marked([BUILTIN_GOAL, SKILL_GOAL])).toEqual(['goal:false']) -}) - -it('keeps the built-in mark through stream classification and re-derives it on replacement', () => { - const catalog = new ClaudeSlashCommandCatalog(undefined, { - commands: [BUILTIN_CLEAR, BUILTIN_GOAL] - }) - const goal = () => catalog.commands?.find((entry) => entry.name === 'goal') - expect(goal()).toMatchObject({ kind: 'command', kindUnspecified: true, builtin: true }) - catalog.observe(init({ slash_commands: ['clear', 'goal'], skills: [] })) - expect(goal()).toEqual({ - name: 'goal', - kind: 'command', - description: BUILTIN_GOAL.description, - builtin: true - }) - const reload = { type: 'system', subtype: 'commands_changed' } - expect(catalog.observe({ ...reload, commands: [BUILTIN_CLEAR, BUILTIN_GOAL] })).toBe(false) - expect(catalog.observe({ ...reload, slash_commands: ['clear', 'goal'], skills: ['goal'] })).toBe( - true - ) - expect(goal()).toMatchObject({ kind: 'skill' }) - expect(goal()?.builtin).toBeUndefined() - catalog.observe(init({ slash_commands: ['clear', 'goal'], skills: [] })) - expect(goal()?.builtin).toBe(true) - expect(catalog.observe({ ...reload, commands: [BUILTIN_CLEAR, SKILL_GOAL] })).toBe(true) - expect(goal()?.builtin).toBeUndefined() - catalog.observe({ ...reload, commands: [BUILTIN_CLEAR] }) - expect(goal()).toBeUndefined() -}) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index 80064b11155..ff888801f42 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -6,8 +6,7 @@ import type * as ManagedProviderProcessModule from '../provider-process/managed- import type { query } from '@anthropic-ai/claude-agent-sdk' import { CLAUDE_READER_DRAIN_AFTER_EXIT_MS, - openClaudeStreamJsonConnection, - type ClaudeStreamJsonLaunch + openClaudeStreamJsonConnection } from './claude-stream-json-connection' const mocks = vi.hoisted(() => { @@ -157,115 +156,6 @@ describe('Claude stream-json close ordering', () => { await expect(connection.close()).resolves.toBe(true) }) - it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { - mocks.refresh.mockReset() - mocks.closeWriter.mockReset() - mocks.refresh.mockResolvedValue(undefined) - mocks.closeWriter.mockResolvedValue(true) - const stdout = new PassThrough() - const stderr = new PassThrough() - const child = fakeChild({ stdout, stderr }) - const next = vi - .fn<() => Promise>>>() - .mockResolvedValue({ value: undefined, done: true }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. - const queryImpl = ((params: Parameters[0]) => { - params.options?.spawnClaudeCodeProcess?.({ - command: 'claude', - args: [], - env: {}, - signal: new AbortController().signal - }) - return { - [Symbol.asyncIterator]: () => ({ next }) - } - }) as unknown as typeof query - const exits: string[] = [] - const connection = await openClaudeStreamJsonConnection( - { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, - { onExit: (error, exit) => exits.push(`${exit?.expected}: ${error.message}`) }, - () => child, - queryImpl - ) - - stderr.write('session limit reached\n') - stdout.end() - await vi.waitFor(() => expect(next).toHaveBeenCalledOnce()) - await new Promise((resolve) => setImmediate(resolve)) - expect(exits).toEqual([]) - - child.emit('exit', 1, null) - await vi.waitFor(() => - expect(exits).toEqual(['false: claude stream-json exited (code 1): session limit reached']) - ) - await connection.close() - }) - - it('returns an unproven close without waiting on a live output reader', async () => { - mocks.refresh.mockReset() - mocks.closeWriter.mockReset() - mocks.refresh.mockResolvedValue(undefined) - mocks.closeWriter.mockResolvedValue(false) - const child = fakeChild() - const next = vi.fn(() => new Promise>>(() => {})) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. - const queryImpl = ((params: Parameters[0]) => { - params.options?.spawnClaudeCodeProcess?.({ - command: 'claude', - args: [], - env: {}, - signal: new AbortController().signal - }) - return { - [Symbol.asyncIterator]: () => ({ next }) - } - }) as unknown as typeof query - const connection = await openClaudeStreamJsonConnection( - { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, - {}, - () => child, - queryImpl - ) - - await expect(connection.close()).resolves.toBe(false) - expect(next).toHaveBeenCalledOnce() - }) - - it('runs the stop ladder again on a close after an unproven attempt, refusing input meanwhile', async () => { - mocks.refresh.mockReset() - mocks.closeWriter.mockReset() - mocks.refresh.mockResolvedValue(undefined) - mocks.closeWriter.mockResolvedValueOnce(false).mockResolvedValueOnce(true) - const child = fakeChild() - const next = vi.fn(async (): Promise>> => ({ - value: undefined, - done: true - })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. - const queryImpl = ((params: Parameters[0]) => { - params.options?.spawnClaudeCodeProcess?.({ - command: 'claude', - args: [], - env: {}, - signal: new AbortController().signal - }) - return { - [Symbol.asyncIterator]: () => ({ next }) - } - }) as unknown as typeof query - const connection = await openClaudeStreamJsonConnection( - { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, - {}, - () => child, - queryImpl - ) - - await expect(connection.close()).resolves.toBe(false) - await expect(connection.send({ type: 'user' })).rejects.toThrow() - await expect(connection.close()).resolves.toBe(true) - expect(mocks.closeWriter).toHaveBeenCalledTimes(2) - }) - // Inherited pipes must not keep sends and Stops waiting after proven exit. it('resolves a proven close though the output never ends', async () => { mocks.refresh.mockReset() @@ -312,124 +202,4 @@ describe('Claude stream-json close ordering', () => { warn.mockRestore() } }) - - it('reports a root that exits after its close came back unproven as that close ending', async () => { - mocks.refresh.mockReset() - mocks.closeWriter.mockReset() - mocks.refresh.mockResolvedValue(undefined) - mocks.closeWriter.mockResolvedValue(false) - const child = fakeChild() - const next = vi.fn(() => new Promise>>(() => {})) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. - const queryImpl = ((params: Parameters[0]) => { - params.options?.spawnClaudeCodeProcess?.({ - command: 'claude', - args: [], - env: {}, - signal: new AbortController().signal - }) - return { - [Symbol.asyncIterator]: () => ({ next }) - } - }) as unknown as typeof query - const exits: (boolean | undefined)[] = [] - const connection = await openClaudeStreamJsonConnection( - { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, - { onExit: (_error, exit) => exits.push(exit?.expected) }, - () => child, - queryImpl - ) - await expect(connection.close()).resolves.toBe(false) - - child.emit('exit', 0, 'SIGTERM') - - expect(exits).toEqual([true]) - }) - - it('waits for the live tree refresh before ending stdin', async () => { - const refreshDone = Promise.withResolvers() - mocks.refresh.mockReturnValueOnce(refreshDone.promise) - mocks.closeWriter.mockResolvedValueOnce(true) - const child = fakeChild() - const launch: ClaudeStreamJsonLaunch = { - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: '/work/repo' - } - const queryImpl = ((params: Parameters[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.closeWriter.mockReset() - const outputCapture = Promise.withResolvers() - const closeCapture = Promise.withResolvers() - mocks.refresh - .mockReturnValueOnce(outputCapture.promise) - .mockReturnValueOnce(closeCapture.promise) - mocks.closeWriter.mockResolvedValueOnce(true) - const child = fakeChild() - const launch: ClaudeStreamJsonLaunch = { - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: '/work/repo' - } - const queryImpl = ((params: Parameters[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) - }) }) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 7d9ab1adef0..f9a721cc24f 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { providerDiagnosticOf } from '../../shared/agent-session/failures/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess } from '@orca/process-host' @@ -24,17 +24,6 @@ import { import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' import { providerExecutableMissing } from '../provider-process/provider-executable-missing' -import { findInErrorChain } from '../provider-process/provider-error-chain' -import { providerExitObserved } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' -import { - adapterAtPublishFor, - fakeClaude, - identityFor -} from './structured/claude-structured-session-test-support' -import { claudeStartFailure } from './claude-startup-failure' - -const retryableStructuredAgentSessionStart = (error: unknown): boolean => - claudeStartFailure(error).retry === 'safe' // Exercise the real SDK against a scripted child CLI. const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs') @@ -123,15 +112,6 @@ async function open( return connection } -function launchedArgv(spec: ProcessSpec | undefined): string[] { - const argv = [spec?.program ?? '', ...(spec?.args ?? [])] - return spec?.env?.ORCA_PROVIDER_SUPERVISOR_SPEC ? argv.slice(argv.indexOf('--') + 1) : argv -} - -function childEnv(): Record { - return (spawned.at(-1)?.env ?? {}) as Record -} - async function until(read: () => T | null | undefined, label: string): Promise { for (let attempt = 0; attempt < 400; attempt++) { const value = read() @@ -167,109 +147,6 @@ function processState(pid: number): 'running' | 'exited' { } describe('Claude stream-json connection', () => { - 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') - // Clear inherited overrides to exercise the SDK default. - vi.stubEnv('CLAUDE_CODE_ENTRYPOINT', undefined) - vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited') - vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd') - const scenario = scriptScenario([HOLD_OPEN]) - const connection = await open( - launchFor(scenario, { - ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', - 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' - }) - ) - - expect(connection.pid).toEqual(expect.any(Number)) - expect(() => process.kill(connection.pid as number, 0)).not.toThrow() - const env = childEnv() - // Credential lookup keys on the literal config-dir 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.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') - 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() - expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts') - expect(env.NODE_OPTIONS).toBeUndefined() - const report = await until(() => readReportSafely(scenario), 'the scripted CLI report') - expect(report.argv[0]).toBe(FAKE_CLI) - // The SDK runs .mjs fixtures under Node; inspect provider argv inside the supervisor spec when present. - expect(launchedArgv(spawned.at(-1))).toContain(FAKE_CLI) - expect(report.argv).toContain('--replay-user-messages') - expect(report.argv).toContain(`--session-id=${SESSION_ID}`) - }) - - it('drops only an API key the user rejected in the TUI, as interactive Claude does', async () => { - const configDir = mkdtempSync(join(tmpdir(), 'claude-rejected-key-')) - scratchDirs.push(configDir) - const rejectedKey = 'sk-ant-api03-REJECTED-0123456789abcdefghij' - writeFileSync( - join(configDir, '.claude.json'), - JSON.stringify({ - customApiKeyResponses: { approved: [], rejected: [rejectedKey.slice(-20)] } - }) - ) - await open( - launchFor(scriptScenario([HOLD_OPEN]), { - CLAUDE_CONFIG_DIR: configDir, - ANTHROPIC_API_KEY: ` ${rejectedKey}\n` - }) - ) - expect(childEnv().ANTHROPIC_API_KEY).toBeUndefined() - - await open( - launchFor(scriptScenario([HOLD_OPEN]), { - CLAUDE_CONFIG_DIR: configDir, - ANTHROPIC_API_KEY: 'sk-ant-api03-0123456789abcdef-INTENDED' - }) - ) - expect(childEnv().ANTHROPIC_API_KEY).toBe('sk-ant-api03-0123456789abcdef-INTENDED') - }) - - it("strips the launch's inherited keys after Orca's own env is merged in", async () => { - vi.stubEnv('ORCA_PANE_KEY', 'tab-1:pane-1') - vi.stubEnv('ORCA_AGENT_HOOK_PORT', '4242') - vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited') - const scenario = scriptScenario([HOLD_OPEN]) - await open({ - ...launchFor(scenario, { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }), - envToDelete: ['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT'] - }) - - await until(() => readReportSafely(scenario), 'the scripted CLI report') - const env = childEnv() - expect(env.ORCA_PANE_KEY).toBeUndefined() - expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() - expect(env.ORCA_CONNECTION_MARKER).toBe('inherited') - expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') - }) - - it("drops a CLAUDE_CONFIG_DIR from Orca's own process env: only the launch may name a home", async () => { - vi.stubEnv('CLAUDE_CONFIG_DIR', '/orca/process/claude') - vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited') - const scenario = scriptScenario([HOLD_OPEN]) - await open(launchFor(scenario)) - - await until(() => readReportSafely(scenario), 'the scripted CLI report') - expect(childEnv().CLAUDE_CONFIG_DIR).toBeUndefined() - expect(childEnv().ORCA_CONNECTION_MARKER).toBe('inherited') - - await open(launchFor(scenario, { CLAUDE_CONFIG_DIR: '/accounts/managed/home' })) - expect(childEnv().CLAUDE_CONFIG_DIR).toBe('/accounts/managed/home') - }) - it('settles a send only once the frame reached the child, and replays reach onMessage', async () => { const replay = { type: 'user', @@ -513,79 +390,6 @@ describe('Claude stream-json connection', () => { } }) - it('lets a final initialize answer after a physical exit own the start despite fault-first callbacks', async () => { - const go = join(mkdtempSync(join(tmpdir(), 'claude-sdk-exit-answer-')), 'go') - scratchDirs.push(dirname(go)) - const scenario = scriptScenario([ - { awaitFile: { path: go, timeoutMs: 15_000 } }, - { delayMs: 20 }, - { exit: 1 } - ]) - // Real SDK and child; only initialize's answer is held so it can arrive after the exit. - const { query: sdkQuery } = await import('@anthropic-ai/claude-agent-sdk') - const answer = Promise.withResolvers() - const queryImpl: typeof query = (args) => - Object.assign(sdkQuery(args), { initializationResult: () => answer.promise }) - const reports: string[] = [] - const exited = Promise.withResolvers() - const claude = fakeClaude() - const adapter = adapterAtPublishFor({ - ...claude, - openConnection: (_launch, handlers = {}) => - open( - launchFor(scenario), - { - ...handlers, - onFault: (error) => { - reports.push(providerExitObserved(error) ? 'exit-fault' : 'fault') - handlers.onFault?.(error) - }, - onExit: (error, exit) => { - reports.push('exit') - handlers.onExit?.(error, exit) - exited.resolve() - } - }, - queryImpl - ) - }) - try { - const acquired = await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 's' }) - let settled = false - const opened = adapter - .open({ - sessionId: 'session-1', - fence: 7, - acquisitionGeneration: acquired.acquisitionGeneration, - signal: new AbortController().signal - }) - .then( - () => null, - (error: unknown) => error - ) - .finally(() => { - settled = true - }) - writeFileSync(go, '') - await exited.promise - // A fault-first start would already have settled; one macrotask drains its rejection chain. - await new Promise((resolve) => setImmediate(resolve)) - - expect(reports).toEqual(['exit-fault', 'exit']) - expect(settled).toBe(false) - answer.reject( - Object.assign(new Error('initialize failed'), { errorClass: 'control_request_failed' }) - ) - const failure = await opened - expect( - findInErrorChain(failure, (inner) => ('errorClass' in inner ? inner.errorClass : undefined)) - ).toBe('control_request_failed') - expect(retryableStructuredAgentSessionStart(failure)).toBe(false) - } finally { - await adapter.closeAll() - } - }) - it.runIf(process.platform !== 'win32')( 'proves a natural SDK exit and cleans up its descendant before recovery', async () => { @@ -701,16 +505,4 @@ describe('Claude stream-json connection', () => { 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' } - }) - - // Cross the displayed start-time second before lstart verification. - await new Promise((resolve) => setTimeout(resolve, 1_100)) - await expect(connection.close()).resolves.toBe(true) - }, 20_000) }) diff --git a/src/main/claude/claude-subagent-permission-request.test.ts b/src/main/claude/claude-subagent-permission-request.test.ts index 67b77377595..316ffaa1f83 100644 --- a/src/main/claude/claude-subagent-permission-request.test.ts +++ b/src/main/claude/claude-subagent-permission-request.test.ts @@ -8,7 +8,6 @@ import { isDeepStrictEqual } from 'node:util' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseAgentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' import type { - AgentJournalProducerLinkage, AgentJournalRenderItem, AgentJournalResolution } from '../../shared/agent-session/journal/agent-session-journal-types' @@ -17,7 +16,6 @@ import type { AgentStatusStructuredSessionSubject } from '../../shared/agent-sta import { AgentHookServer } from '../agent-hooks/server' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' -import { settleObservedExitForTest } from '../native-chat/agent-session-wire/structured-agent-session-observed-exit.test-fixture' import { StructuredAgentSessionCurrentWork } from '../native-chat/agent-session-wire/structured-agent-session-current-work' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' @@ -25,7 +23,6 @@ import { createStructuredAgentSessionLogger } from '../native-chat/agent-session import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' import { invokeCanUseTool } from './claude-can-use-tool-test-support' -import { system, toolUse } from './claude-child-work-producer-harness.test-fixture' import { ClaudeStructuredSessionAdapter } from './structured/claude-structured-session-adapter' import type { ClaudeStructuredSessionEvent } from './structured/claude-structured-session-state' import { @@ -333,122 +330,7 @@ function answer( }) } -function dismiss(run: Pipeline) { - return run.adapter.dismissPrompt({ - sessionId: SESSION, - itemId: run.cardId(), - fence: FENCE, - answer: false, - commit: run.hostRecords({ state: 'cancelled', selectedOptionId: null }) - }) -} - -async function replay(name: string): Promise { - const run = await pipeline() - const timeline: string[] = [] - for (const event of captured(name)) { - const response = isRecord(event.frame.response) ? event.frame.response : null - const request = isRecord(event.frame.request) ? event.frame.request : null - let label = text(event.frame.subtype) || text(event.frame.type) - if (event.from === 'orca' && event.frame.type === 'control_response' && response) { - const behavior = isRecord(response.response) ? text(response.response.behavior) : '' - await answer(run, behavior === 'deny' ? 'deny' : 'allow') - await run.settle() - label = behavior - } else { - await run.step(event) - label = request?.subtype === 'can_use_tool' ? 'can_use_tool' : label - } - const view = run.viewOf(askerOf(name)) - timeline.push(`${label} -> ${view ? `${view.membership} ${view.state}` : 'none'}`) - } - return { ...run, timeline } -} - -/** Root → agent-a → agent-n; the request can precede agent-n's Bash call. */ -async function nested(asker: string, toolCallFirst = false): Promise { - const run = await pipeline() - const spawn = (id: string, taskId: string, parentRef: string | null) => [ - toolUse(id, 'Agent', { description: taskId, prompt: 'go' }, parentRef), - system('task_started', { - task_id: taskId, - tool_use_id: id, - description: taskId, - task_type: 'local_agent' - }) - ] - const bash = toolUse('toolu_bash_n', 'Bash', { command: 'touch n' }, 'toolu_n') - for (const frame of [ - ...spawn('toolu_a', 'agent-a', null), - ...spawn('toolu_n', 'agent-n', 'toolu_a'), - ...(toolCallFirst ? [bash] : []) - ]) { - await run.step({ from: 'cli', frame }) - } - run.raise('req-n', 'toolu_bash_n', asker) - await run.step({ - from: 'cli', - frame: toolUse('toolu_read_n', 'Read', { file_path: 'n' }, 'toolu_n') - }) - return run -} - -function linkageOf(item: AgentJournalProducerLinkage | undefined) { - return { - agentId: item?.agentId, - parentAgentId: item?.parentAgentId, - providerParentRef: item?.providerParentRef, - producerKind: item?.producerKind, - attempt: item?.attempt - } -} - describe("a Claude subagent's permission request", () => { - it.each([ - [ - 'fg-allow', - [ - 'session_state_changed -> live working', - 'can_use_tool -> live waiting', - 'allow -> live working' - ] - ], - [ - 'bg-allow', - [ - 'session_state_changed -> live working', - 'can_use_tool -> live waiting', - 'assistant -> live waiting', - 'success -> live waiting', - 'allow -> live working' - ] - ] - ])( - 'waits from its request until answered, beside its card, the parent row as before (%s)', - async (name, around) => { - const run = await replay(name) - const at = run.timeline.indexOf('can_use_tool -> live waiting') - expect(run.timeline.slice(at - 1, at - 1 + around.length)).toEqual(around) - expect(run.violations()).toEqual([]) - } - ) - - it('reads blocked as soon as the request arrives, dated by its card', async () => { - const run = await pipeline() - await run.ask('fg-allow') - const card = pendingCards(run.journal.snapshot().items)[0] - expect(run.publishes.at(-1)).toMatchObject({ - waiting: [card?.agentId], - row: { - state: 'blocked', - stateStartedAt: card?.observedAt, - mainAgent: { state: 'blocked', stateStartedAt: card?.observedAt } - } - }) - expect(run.viewOf(ASKER)?.operation).toMatchObject({ toolName: 'Bash' }) - expect(run.violations()).toEqual([]) - }) - it('waits only once its card is written, though the write waits for the journal', async () => { const run = await pipeline() const events = captured('fg-allow') @@ -467,28 +349,6 @@ describe("a Claude subagent's permission request", () => { expect(run.violations()).toEqual([]) }) - it.each(['allowed', 'denied', 'dismissed and left to the Stop', 'withdrawn by Claude'] as const)( - 'frees the subagent when its request is %s', - async (how) => { - const run = await pipeline() - await run.ask('fg-allow') - if (how === 'allowed' || how === 'denied') { - await answer(run, how === 'allowed' ? 'allow' : 'deny') - } else if (how === 'dismissed and left to the Stop') { - await dismiss(run) - } - await run.settle() - if (how !== 'allowed' && how !== 'denied') { - for (const controller of run.aborts.values()) { - controller.abort() - } - await run.settle() - } - expect(run.publishes.at(-1)?.waiting).toEqual([]) - expect(run.violations()).toEqual([]) - } - ) - it('waits again, beside its card, when the host fails to record the answer', async () => { const run = await pipeline() await run.ask('fg-allow') @@ -502,44 +362,6 @@ describe("a Claude subagent's permission request", () => { expect(run.violations()).toEqual([]) }) - it('keeps an answered request in the rows of the subagent that asked', async () => { - const run = await replay('fg-allow') - const card = run.journal.snapshot().items.find((item) => item.body.kind === 'approval') - expect(card?.agentId).toBe(ASKER) - expect(card?.body).toMatchObject({ - resolution: { state: 'resolved', selectedOptionId: 'allow' } - }) - }) - - it('names the subagent through the tool call it gates when the CLI does not', async () => { - const run = await pipeline() - await run.ask('fg-allow', { withoutAgentId: true }) - expect(pendingCards(run.journal.snapshot().items)[0]?.agentId).toBe(ASKER) - expect(run.publishes.at(-1)?.waiting).toEqual([ASKER]) - expect(run.violations()).toEqual([]) - }) - - it("gives a nested subagent's request the linkage its own rows carry", async () => { - const run = await nested('agent-n') - const items = run.journal.snapshot().items - const card = items.find((item) => item.body.kind === 'approval') - const sibling = items.find( - (item) => item.body.kind === 'tool-call' && item.agentId === 'agent-n' - ) - expect(linkageOf(card)).toEqual(linkageOf(sibling)) - expect(card).toMatchObject({ agentId: 'agent-n', parentAgentId: 'agent-a' }) - }) - - it("files the request under the agent the CLI names when the gated call is another's", async () => { - const run = await nested('agent-a', true) - const items = run.journal.snapshot().items - const card = items.find((item) => item.body.kind === 'approval') - const askerRow = items.find( - (item) => item.body.kind === 'tool-call' && item.agentId === 'agent-a' - ) - expect(linkageOf(card)).toEqual(linkageOf(askerRow)) - }) - it('waits each asking subagent beside its own card, and only those', async () => { const run = await pipeline() await run.ask('fg-allow') @@ -573,60 +395,4 @@ describe("a Claude subagent's permission request", () => { expect(run.publishes.at(-1)?.waiting).toEqual([]) expect(run.violations()).toEqual([]) }) - - it("stays blocked on the session's own request after its subagent's is answered", async () => { - const run = await pipeline() - await run.ask('fg-allow') - run.raise('req-main', 'toolu_main_bash') - await run.settle() - const cards = pendingCards(run.journal.snapshot().items) - expect(cards.map((card) => card.agentId ?? null).sort()).toEqual([ASKER, null].sort()) - // The root request takes precedence over the earlier child request. - const own = cards.find((card) => !card.agentId)?.observedAt - expect(own).toBeGreaterThan( - cards.find((card) => card.agentId === ASKER)?.observedAt ?? Infinity - ) - expect(run.publishes.at(-1)).toMatchObject({ - waiting: [ASKER], - row: { state: 'blocked', stateStartedAt: own, mainAgent: { stateStartedAt: own } } - }) - const subagents = cards.find((card) => card.agentId === ASKER)?.itemId - await answer( - run, - 'allow', - run.hostRecords({ state: 'resolved', selectedOptionId: 'allow' }, subagents), - subagents - ) - await run.settle() - expect(run.publishes.at(-1)).toMatchObject({ waiting: [], row: { state: 'blocked' } }) - expect(run.violations()).toEqual([]) - }) - - it('stops the subagent waiting when the process dies mid-request', async () => { - const run = await pipeline() - await run.ask('fg-allow') - expect(run.publishes.at(-1)?.waiting).toEqual([ASKER]) - let settled: Promise | undefined - hooks.onEvent = (event) => { - if (event.type === 'ended') { - settled = settleObservedExitForTest({ - journal: run.journal, - sessionId: SESSION, - fence: FENCE, - settlementId: 'provider-exit:test', - verdict: { state: 'interrupted', completedAt: run.now() }, - pendingSubmissionReason: 'provider_exited_before_acknowledgement' - }) - } - } - run.connection.handlers.onExit?.(new Error('claude crashed')) - for (let attempt = 0; attempt < 20 && !settled; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 5)) - } - await settled - await run.settle() - expect(pendingCards(run.journal.snapshot().items)).toEqual([]) - expect(run.publishes.at(-1)?.waiting).toEqual([]) - expect(run.violations()).toEqual([]) - }) }) diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts index f526e8086a6..2b8446fc801 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -188,24 +188,4 @@ describe('a Claude subagent resumed after its provider restarted', () => { expect(proseRow(journal, 'B checks the rig')?.attempt).toBe(2) await second.exit() }) - - it('still files a child no journaled row resolved under its own reference, never the parent', async () => { - const journal = await openJournal() - const first = acquire(journal) - first.translator.handle(userTurn('turn-a')) - first.translator.handle(result('result-a')) - await first.exit() - - const second = acquire(journal) - second.translator.handle(userTurn('turn-b')) - second.translator.handle(taskStarted('agent-other', 'toolu_other', 'Other')) - second.translator.handle(childSays('c-1', 'toolu_unknown', 'nobody named me')) - await second.settle() - - expect(proseRow(journal, 'nobody named me')).toMatchObject({ - agentId: 'toolu_unknown', - providerParentRef: 'toolu_unknown' - }) - await second.exit() - }) }) diff --git a/src/main/claude/claude-thread-goal-journaling.test.ts b/src/main/claude/claude-thread-goal-journaling.test.ts deleted file mode 100644 index 987fc8a648f..00000000000 --- a/src/main/claude/claude-thread-goal-journaling.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalThreadGoal -} from '../../shared/agent-session/journal/agent-session-journal-types' -import type { AgentJournalItemIdentity } from '../../shared/agent-session/journal/agent-session-journal-identity' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation' -import { journal } from './claude-context-usage-test-support' -import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' -import { MOVED_TO_BACKGROUND } from './claude-captured-task-frames.test-fixture' - -// Captured from Claude Code 2.1.280: synthetic /goal replies, with no goal frame on completion. -const OBJECTIVE = 'reply with exactly the word done' - -function goalSink( - journalGoal: AgentJournalThreadGoal | null = null, - journalRow?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }, - deferred = false -) { - const state = journal({ deferred }) - const rows = state.appends - let stopped = false - const backgroundTasks = new ClaudeBackgroundTaskTracker() - const sink: StructuredAgentSessionEventSink = { - ...state.sink, - appendTombstone: vi.fn(), - publish: vi.fn(), - journalStopDecidesTurn: () => stopped, - journalLinkage: () => ({ - epoch: 'test', - visitItemsWithLinkage: () => {}, - threadGoal: () => journalGoal, - threadGoalSource: () => ({ - goal: journalGoal, - row: - journalRow?.body.kind === 'status' - ? { identity: journalRow.identity, text: journalRow.body.text } - : null - }) - }) - } - const translator = createClaudeJournalTranslator({ - sink, - coalesceMs: 0, - goalBackgroundWork: () => backgroundTasks.goalBackgroundWork - }) - let at = 1_000 - const frame = (message: Record, elapsedMs = 1_000) => { - at += elapsedMs - state.clock.now = at - backgroundTasks.observe(message) - translator.handle({ - type: 'message', - sessionId: 'orca-session', - observedAt: at, - message: { session_id: 'claude-session', parent_tool_use_id: null, ...message } - }) - } - const reply = (uuid: string, model: string, text: string) => - frame({ - type: 'assistant', - uuid, - message: { role: 'assistant', model, content: [{ type: 'text', text }] } - }) - const result = (uuid: string, fields: Record, elapsedMs?: number) => - frame({ type: 'result', uuid, ...fields }, elapsedMs) - const goals = () => - rows.flatMap(({ identity, body }) => - body.kind === 'status' && body.threadGoal ? [{ identity, body }] : [] - ) - const savedGoal = () => { - const body = goals().at(-1)?.body - return body?.kind === 'status' && body.threadGoal?.state === 'set' ? body.threadGoal.goal : null - } - const pressStop = () => { - stopped = true - } - return { frame, reply, result, goals, savedGoal, pressStop, flush: state.flush } -} - -describe('Claude /goal journaling', () => { - it.each([false, true])( - 'journals the goal and its completion before adjacent idle (deferred %s)', - (deferred) => { - const { frame, reply, result, goals, flush } = goalSink(null, undefined, deferred) - reply('goal-set', '', `Goal set: ${OBJECTIVE}`) - reply('work-1', 'claude-haiku-4-5', 'Working on it.') - result( - 'result-1', - { subtype: 'error_max_turns', is_error: true, terminal_reason: 'max_turns' }, - 5_000 - ) - frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1) - reply('work-2', 'claude-haiku-4-5', 'done') - result( - 'result-2', - { subtype: 'success', is_error: false, terminal_reason: 'completed' }, - 3_000 - ) - frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1) - flush() - - expect(goals()).toEqual([ - { - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' }, - body: expect.objectContaining({ - text: `Goal set: ${OBJECTIVE}`, - threadGoal: { - state: 'set', - goal: expect.objectContaining({ - objective: OBJECTIVE, - status: 'active', - timeUsedSeconds: 0 - }) - } - }) - }, - { - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' }, - body: expect.objectContaining({ - text: `Goal set: ${OBJECTIVE}`, - threadGoal: { - state: 'set', - goal: expect.objectContaining({ status: 'active', timeUsedSeconds: 6 }) - } - }) - }, - { - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'result-2:goal' }, - body: expect.objectContaining({ - text: `Goal complete: ${OBJECTIVE}`, - threadGoal: { - state: 'set', - goal: expect.objectContaining({ status: 'complete', timeUsedSeconds: 10 }) - } - }) - } - ]) - } - ) - - // Captured Stop during the goal check returned success. - it('keeps the goal open when a person stops the turn, even on a clean result', () => { - const { reply, result, goals, pressStop } = goalSink() - reply('goal-set', '', `Goal set: ${OBJECTIVE}`) - reply('work', 'claude-opus-5-5', 'Still waiting.') - pressStop() - result('result', { subtype: 'success', is_error: false, terminal_reason: 'completed' }) - - expect(goals().map(({ body }) => body.kind === 'status' && body.text)).toEqual([ - `Goal set: ${OBJECTIVE}`, - `Goal set: ${OBJECTIVE}` - ]) - expect(goals().at(-1)?.body).toMatchObject({ threadGoal: { goal: { status: 'active' } } }) - }) - - it('keeps the goal active when a captured background Agent outlives a clean root result', () => { - const { frame, reply, goals } = goalSink() - reply('goal-set', '', `Goal set: ${OBJECTIVE}`) - let previousAt = 0 - for (const [index, captured] of MOVED_TO_BACKGROUND.entries()) { - frame({ uuid: `capture-${index}`, ...captured.frame }, captured.at - previousAt) - previousAt = captured.at - if (captured.frame.type === 'result') { - break - } - } - - expect(goals().at(-1)?.body).toMatchObject({ - threadGoal: { goal: { status: 'active' } } - }) - expect( - goals().some(({ body }) => body.kind === 'status' && body.text.startsWith('Goal complete')) - ).toBe(false) - }) - - it('saves elapsed goal time after restart when a person stops the turn', () => { - const beforeRestart = goalSink() - beforeRestart.reply('goal-set', '', `Goal set: ${OBJECTIVE}`) - - const afterRestart = goalSink(beforeRestart.savedGoal(), beforeRestart.goals().at(-1)) - afterRestart.reply('work', 'claude-opus-5-5', 'Still waiting.') - afterRestart.pressStop() - afterRestart.result( - 'stopped', - { subtype: 'success', is_error: false, terminal_reason: 'completed' }, - 5_000 - ) - - expect(afterRestart.goals()).toEqual([ - { - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'goal-set' }, - body: expect.objectContaining({ - text: `Goal set: ${OBJECTIVE}`, - threadGoal: { - state: 'set', - goal: expect.objectContaining({ status: 'active', timeUsedSeconds: 5 }) - } - }) - } - ]) - - const reloaded = goalSink(afterRestart.savedGoal(), afterRestart.goals().at(-1)) - reloaded.reply('status', '', `Goal active: ${OBJECTIVE} (not yet evaluated)`) - expect(reloaded.goals()[0]?.body).toMatchObject({ - threadGoal: { goal: { status: 'active', timeUsedSeconds: 5 } } - }) - }) - - it('keeps a goal set before a restart open through a status reply, and clears it on /goal clear', () => { - const objective = 'Ship (2 turns) then go' - const { reply, result, goals } = goalSink({ - objective, - status: 'active', - tokenBudget: null, - tokensUsed: 0, - timeUsedSeconds: 30, - createdAt: 1, - updatedAt: 1 - }) - reply('status', '', `Goal active: ${objective} (not yet evaluated)`) - result('status-result', { subtype: 'success', is_error: false, num_turns: 0 }) - reply('clear', '', `Goal cleared: ${objective}`) - - expect(goals().map(({ body }) => body.kind === 'status' && body.threadGoal)).toEqual([ - { - state: 'set', - goal: expect.objectContaining({ objective, status: 'active', timeUsedSeconds: 30 }) - }, - { state: 'cleared' } - ]) - }) -}) diff --git a/src/main/claude/claude-transcript-catch-up.test.ts b/src/main/claude/claude-transcript-catch-up.test.ts index 209e55085fb..a75e2510529 100644 --- a/src/main/claude/claude-transcript-catch-up.test.ts +++ b/src/main/claude/claude-transcript-catch-up.test.ts @@ -12,7 +12,6 @@ import { mergeItems } from '../../shared/agent-session/view/structured-agent-session-history-state' import { AgentJournalRenderItemSchema } from '../../shared/agent-session/journal/agent-session-journal-schemas' -import { projectStructuredItemToNativeChat } from '../../shared/agent-session/view/structured-agent-session-projection' import { agentSessionSendBodyFingerprint } from '../../shared/agent-session/requests/structured-agent-session-send-mutation' import { dispatchClaudeTurn } from './structured/claude-structured-dispatch' import { claudePromptUuidForSubmission } from './structured/claude-structured-session-id' @@ -24,7 +23,6 @@ import { import { SESSION, SID, - at, prompt, reply, attachment, @@ -210,81 +208,6 @@ describe('Claude chat catch-up', () => { } ) - it('keeps outside message times and order without splitting queued work', async () => { - const c = await chat() - await c.live() - await c.write([ - ...chatTurn, - prompt('p2', 'a1', 'outside request', 3), - reply( - 'a2', - 'p2', - 'msg_b', - { type: 'tool_use', id: 'toolu_time', name: 'Read', input: {} }, - 4 - ), - { - ...attachment('queued-row', 'a2'), - timestamp: at(5), - attachment: { - type: 'queued_command', - source_uuid: 'cmd-1', - commandMode: 'prompt', - origin: { kind: 'human' }, - humanTurn: true, - prompt: 'follow up' - } - }, - reply('a3', 'queued-row', 'msg_c', { type: 'text', text: 'finished' }, 6), - { - ...prompt('r-time', 'a3', '', 7), - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: 'toolu_time', content: 'done' }] - } - }, - { - ...reply('a5', 'r-time', 'msg_missing', { type: 'text', text: 'no source time' }, 8), - timestamp: undefined - } - ]) - expect(await c.open('a1')).toBe(true) - const key = (uuid: string) => - agentJournalItemKey( - uuid === 'toolu_time' - ? { provider: 'orca', clientMessageId: `claude-tool:${SID}:${uuid}` } - : { provider: 'claude', sessionId: SID, uuid } - ) - const queued = c.journal.item(key('cmd-1')) - expect(queued?.turnScope).toEqual(c.journal.item(key('toolu_time'))?.turnScope) - expect(queued?.turnScope).toMatchObject({ kind: 'turn' }) - expect(c.journal.item(key('block:msg_c:0'))?.turnScope).toEqual(queued?.turnScope) - expect(c.journal.snapshot().items.filter((row) => row.body.kind === 'turn')).toHaveLength(2) - expect(isStructuredRewindTarget(c.journal, key('cmd-1'))).toBe(false) - expect(isStructuredRewindTarget(c.journal, key('p2'))).toBe(true) - const items = c.journal - .snapshot() - .items.filter((item) => - ['p2', 'toolu_time', 'cmd-1', 'block:msg_c:0', 'block:msg_missing:0'].some( - (uuid) => item.itemId === key(uuid) - ) - ) - expect(items.map((item) => [item.itemId, item.observedAt])).toEqual([ - [key('p2'), Date.parse(at(3))], - [key('toolu_time'), Date.parse(at(4))], - [key('cmd-1'), Date.parse(at(5))], - [key('block:msg_c:0'), Date.parse(at(6))], - [key('block:msg_missing:0'), 1_000] - ]) - expect(items.map((item) => projectStructuredItemToNativeChat(item)?.timestamp)).toEqual( - items.map((item) => item.observedAt) - ) - expect(new Set(items.map((item) => item.sequence)).size).toBe(1) - const imported = c.journal.snapshot().items - expect(await c.open('a1')).toBe(true) - expect(c.journal.snapshot().items).toEqual(imported) - expect((await c.replay()).snapshot().items).toEqual(imported) - }) it.each([ ['-work', '/work', false], ['-work-caf-', '/work/cafe\u0301', false], @@ -410,33 +333,6 @@ describe('Claude chat catch-up', () => { live.dispose() }) - it('cancels fallback discovery and cannot commit a late result', async () => { - const c = await chat('-old-work', '/new-work') - await c.write([...chatTurn, ...outsideTurn]) - const lookup = Promise.withResolvers() - const lookupStarted = Promise.withResolvers() - const resolve = vi - .spyOn(sessionFileResolver, 'resolveSessionFilePath') - .mockImplementationOnce(() => { - lookupStarted.resolve() - return lookup.promise - }) - // Fire the deadline only once the lookup is in flight; a real 5 ms timer raced slow runners. - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) - try { - const opened = c.open('a1', { timeoutMs: 5 }) - await lookupStarted.promise - vi.advanceTimersByTime(5) - expect(await opened).toBe(false) - } finally { - vi.useRealTimers() - } - expect(resolve.mock.calls[0]?.[3]?.aborted).toBe(true) - lookup.resolve(null) - await new Promise((done) => setImmediate(done)) - expect(c.rows()).toEqual([]) - }) - it('shows the turns made outside the chat once, in order, under one divider', async () => { const c = await chat() await c.live() @@ -466,56 +362,6 @@ describe('Claude chat catch-up', () => { expect(c.warn).not.toHaveBeenCalled() }) - it('keeps an outside Stop hook row inside the turn it sent back', async () => { - const c = await chat() - await c.live() - await c.write([ - ...chatTurn, - prompt('p2', 'a1', 'ask 2', 3), - reply('a2', 'p2', 'msg_b', { type: 'text', text: 'answer 2' }, 4), - { - ...prompt('h2', 'a2', 'Stop hook feedback:\n[/hook.sh]: Cite evidence.\n', 5), - isMeta: true - }, - reply('a3', 'h2', 'msg_c', { type: 'text', text: 'evidence' }, 6), - ...bookkeeping - ]) - await c.open('a1') - expect(c.rows()).toEqual([ - 'user:ask 1', - 'assistant:answer 1', - '(continued-outside)', - 'user:ask 2', - 'assistant:answer 2', - '(hook-sent-back)', - 'assistant:evidence' - ]) - const scopes = c.journal - .snapshot() - .items.filter((item) => item.body.kind !== 'turn') - .slice(-3) - .map((item) => JSON.stringify(item.turnScope)) - expect(new Set(scopes).size).toBe(1) - }) - - it('imports a later outside batch from the retained leaf without reusing its old divider', async () => { - const c = await chat() - await c.live() - await c.write([...chatTurn, ...outsideTurn]) - expect(await c.open('a1')).toBe(true) - const before = c.rows() - await c.write([ - prompt('p3', 'a3', 'ask 3', 7), - reply('a4', 'p3', 'msg_d', { type: 'text', text: 'answer 3' }, 8) - ]) - expect(await c.open('a1')).toBe(true) - expect(c.rows()).toEqual([...before, '(continued-outside)', 'user:ask 3', 'assistant:answer 3']) - const imported = c.journal.snapshot().items - expect(await c.open('a1')).toBe(true) - expect(c.journal.snapshot().items).toEqual(imported) - expect(c.warn).not.toHaveBeenCalled() - }) - // P saved aliases plus P unacknowledged queued prompts: two J-item indexes, not P×J scans. it.each([10, 100].flatMap((prompts) => [1_000, 10_000].map((items) => ({ prompts, items }))))( 'bounds retained-leaf work for $prompts aliases and local misses over $items journal items', @@ -738,22 +584,6 @@ describe('Claude chat catch-up', () => { expect(c.warn).not.toHaveBeenCalled() }) - it('marks turns that continue from an earlier message than the chat last showed', async () => { - const c = await chat() - await c.live() - await c.write([ - ...chatTurn, - prompt('p3', 'p1', 'ask 1, edited', 7), - reply('a4', 'p3', 'msg_d', { type: 'text', text: 'answer 1, edited' }, 8) - ]) - await c.open('a1') - expect(c.rows().slice(2)).toEqual([ - '(continued-outside-rewound)', - 'user:ask 1, edited', - 'assistant:answer 1, edited' - ]) - }) - it('reads an outside reply that spans many read chunks whole', async () => { const c = await chat() await c.live() diff --git a/src/main/claude/claude-transcript-duration.test.ts b/src/main/claude/claude-transcript-duration.test.ts index 6def38f4535..196aed1c376 100644 --- a/src/main/claude/claude-transcript-duration.test.ts +++ b/src/main/claude/claude-transcript-duration.test.ts @@ -2,15 +2,7 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' import { selectStructuredAgentSettledTurns } from '../../shared/agent-session/view/structured-agent-session-turn-timing' import { isStructuredRewindTarget } from '../native-chat/agent-session-wire/structured-rewind-cut' -import { - SID, - at, - attachment, - chat, - chatTurn, - prompt, - reply -} from './claude-transcript-catch-up-test-support' +import { SID, chat, chatTurn } from './claude-transcript-catch-up-test-support' // Captured CLI 2.1.308 ancestry: retain source identities, clocks, content and cycle boundaries. // Only the session and first parent are rebound to the shared disk/journal fixture below. @@ -247,27 +239,6 @@ const capturedDurationRows: Record[] = [ ] const key = (uuid: string) => agentJournalItemKey({ provider: 'claude', sessionId: SID, uuid }) -const queued = (uuid: string, parent: string, n: number) => ({ - ...attachment(uuid, parent), - timestamp: at(n), - attachment: { - type: 'queued_command', - source_uuid: uuid, - commandMode: 'prompt', - origin: { kind: 'human' }, - humanTurn: true, - prompt: 'queued input' - } -}) -const boundary = (uuid: string, parentUuid: string, timestamp: string) => ({ - type: 'system', - subtype: 'turn_duration', - uuid, - parentUuid, - sessionId: SID, - timestamp, - durationMs: 'not a stopwatch' -}) describe('Claude historical cycle duration', () => { it('ends captured work at its boundary before later idle, next prompt and exit', async () => { @@ -355,62 +326,4 @@ describe('Claude historical cycle duration', () => { expect(c.journal.snapshot()).toEqual(snapshot) expect(c.warn).not.toHaveBeenCalled() }) - - it('uses only current-cycle work fallback and leaves unsupported endpoints untimed', async () => { - const c = await chat() - await c.live() - await c.write([ - ...chatTurn, - prompt('p2', 'a1', 'outside', 3), - reply('a2', 'p2', 'tool', { type: 'tool_use', id: 'tool', name: 'Read', input: {} }, 4), - { - ...prompt('result', 'a2', '', 10), - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: 'tool', content: 'done' }] - } - }, - reply('older', 'result', 'older', { type: 'text', text: 'out of order' }, 8), - { - ...reply('bad-time', 'older', 'bad', { type: 'text', text: 'no clock' }, 9), - timestamp: undefined - }, - queued('later-queue', 'bad-time', 50), - { ...attachment('environment', 'later-queue'), timestamp: at(60) }, - boundary('end2', 'environment', '1970-01-01T00:00:00.000Z'), - prompt('empty', 'end2', 'no response', 70), - boundary('empty-end', 'empty', 'malformed'), - queued('unknown', 'empty-end', 75), - reply('wake', 'unknown', 'wake', { type: 'text', text: 'background' }, 80), - boundary('wake-end', 'wake', at(82)), - prompt('no-work', 'wake-end', 'another empty cycle', 90), - prompt('trailing', 'no-work', 'last request', 100), - reply('last', 'trailing', 'last', { type: 'text', text: 'last response' }, 105), - queued('trailing-queue', 'last', 120) - ]) - expect(await c.open('a1')).toBe(true) - const snapshot = c.journal.snapshot() - const turn = (id: string) => - snapshot.items.find((item) => item.body.kind === 'turn' && item.body.turnId === id)! - expect(turn('p2').body).toMatchObject({ completedAt: Date.parse(at(10)) }) - for (const id of ['empty', 'no-work']) { - expect(turn(id).body).toMatchObject({ state: 'completed' }) - expect(turn(id).body).not.toHaveProperty('completedAt') - expect(turn(id).body).not.toHaveProperty('durationMs') - } - expect(turn('wake').body).toMatchObject({ - completedAt: Date.parse(at(82)), - userItemId: turn('wake').itemId - }) - expect(turn('trailing').body).toMatchObject({ completedAt: Date.parse(at(105)) }) - expect(c.journal.item(key('unknown'))?.turnScope).toEqual({ kind: 'unknown' }) - expect(isStructuredRewindTarget(c.journal, key('unknown'))).toBe(false) - const settled = selectStructuredAgentSettledTurns(snapshot.items, snapshot.submissions) - expect(settled.get(key('empty'))).toBeNull() - expect(settled.get(key('no-work'))).toBeNull() - expect(settled.get(key('trailing'))?.workedSeconds).toBe(5) - expect(await c.open('a1')).toBe(true) - expect(c.journal.snapshot()).toEqual(snapshot) - expect(c.warn).not.toHaveBeenCalled() - }) }) diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts deleted file mode 100644 index 25c56369ad3..00000000000 --- a/src/main/claude/claude-turn-outcome.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../shared/agent-session/journal/agent-session-journal-types' -import type { AgentJournalItemIdentity } from '../../shared/agent-session/journal/agent-session-journal-identity' -import { agentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' -import { - readAgentJournalTurn, - readAgentJournalTurnOutcome -} from '../../shared/agent-session/lifecycle/agent-session-turn-record' -import { describeNativeChatTurnStatus } from '../../shared/native-chat/transcript/native-chat-turn-status' -import { selectStructuredAgentSettledTurns } from '../../shared/agent-session/view/structured-agent-session-turn-timing' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { JournalStopEvent } from '../native-chat/agent-session-journal/journal-row-schema' -import { - personStopDecidesTurn, - type JournalLatestStop -} from '../native-chat/agent-session-journal/journal-stop-turn-end' -import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' -import { claudeResultOutcome } from './claude-result-outcome' -import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation' -import { claudeResultFailure } from './structured/launch/claude-structured-provider-fallback' -import { claudeTurnEndForResult, claudeTurnLifecycleItem } from './claude-turn-lifecycle-item' - -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 userTurn(uuid: 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, - message: { role: 'user', content: [{ type: 'text', text: 'summarize this' }] } - } - } -} - -function result(fields: Record) { - return { type: 'result', duration_ms: 1_200, ...fields } -} - -const TURN = { - sessionId: 'orca-session', - turnId: 'user-1', - startedAt: 1_000, - userItemId: 'claude:user-1' -} - -describe('claudeResultOutcome', () => { - it('separates the provider failing from the user stopping it', () => { - expect(claudeResultOutcome(result({ subtype: 'success', is_error: false }))).toBe('success') - // The SDK marks both stops and faults is_error; terminal_reason distinguishes them. - expect( - claudeResultOutcome(result({ is_error: true, terminal_reason: 'aborted_streaming' })) - ).toBe('cancellation') - expect(claudeResultOutcome(result({ is_error: true, terminal_reason: 'aborted_tools' }))).toBe( - 'cancellation' - ) - expect(claudeResultOutcome(result({ is_error: true, terminal_reason: 'api_error' }))).toBe( - 'failure' - ) - expect(claudeResultOutcome(result({ is_error: true, terminal_reason: 'future_reason' }))).toBe( - 'failure' - ) - expect(claudeResultOutcome(result({ is_error: true }))).toBe('failure') - }) - - it('reads the success-subtype result that carries an API error as a failure', () => { - // Captured API errors use subtype success with is_error true. - expect( - claudeResultOutcome( - result({ - subtype: 'success', - is_error: true, - result: 'API Error: 529 upstream overloaded', - terminal_reason: 'api_error' - }) - ) - ).toBe('failure') - }) - - it('is the single owner of the abort list the visible error row also reads', () => { - for (const reason of ['aborted_streaming', 'aborted_tools', 'api_error', 'future_reason']) { - const message = result({ is_error: true, terminal_reason: reason, result: 'text' }) - expect(claudeResultFailure(message) !== null).toBe(claudeResultOutcome(message) === 'failure') - } - expect(claudeResultFailure(result({ is_error: false }))).toBeNull() - }) -}) - -describe('claudeTurnEndForResult', () => { - it('records the verdict without moving the lifecycle arm', () => { - expect( - claudeTurnEndForResult(result({ is_error: true, terminal_reason: 'api_error' }), 9_000) - ).toEqual({ state: 'completed', outcome: 'failure', completedAt: 9_000, durationMs: 1_200 }) - expect(claudeTurnEndForResult(result({ is_error: false }), 9_000)).toEqual({ - state: 'completed', - outcome: 'success', - completedAt: 9_000, - durationMs: 1_200 - }) - expect( - claudeTurnEndForResult(result({ is_error: true, terminal_reason: 'aborted_tools' }), 9_000) - ).toEqual({ - state: 'interrupted', - outcome: 'cancellation', - completedAt: 9_000, - durationMs: 1_200 - }) - }) -}) - -describe('claudeTurnLifecycleItem', () => { - it('carries the verdict onto the terminal row and never onto the running one', () => { - expect(readAgentJournalTurn(claudeTurnLifecycleItem(TURN).body)).toEqual({ - turnId: 'user-1', - state: 'running', - startedAt: 1_000, - userItemId: 'claude:user-1' - }) - expect( - readAgentJournalTurn( - claudeTurnLifecycleItem(TURN, { - state: 'completed', - outcome: 'failure', - completedAt: 4_000 - }).body - ) - ).toEqual({ - turnId: 'user-1', - state: 'completed', - outcome: 'failure', - startedAt: 1_000, - completedAt: 4_000, - userItemId: 'claude:user-1' - }) - }) - - it('omits the verdict for an end the host inferred rather than heard', () => { - const body = claudeTurnLifecycleItem(TURN, { state: 'interrupted', completedAt: 4_000 }).body - expect(body).not.toHaveProperty('outcome') - expect(readAgentJournalTurn(body)).toEqual({ - turnId: 'user-1', - state: 'interrupted', - startedAt: 1_000, - completedAt: 4_000, - userItemId: 'claude:user-1' - }) - }) -}) - -describe('a turn end the host inferred', () => { - it('records no outcome for a turn ended by the child ending', () => { - const state = sinkState() - const translator = createClaudeJournalTranslator({ sink: state.sink }) - - translator.handle(userTurn('user-1')) - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) - - const settled = state.items.find( - (item) => - item.identity.provider === 'legacy' && - item.identity.recordId === 'turn-lifecycle:user-1' && - readAgentJournalTurn(item.body)?.state === 'interrupted' - ) - expect(readAgentJournalTurn(settled?.body)).toMatchObject({ state: 'interrupted' }) - expect(settled?.body).not.toHaveProperty('outcome') - expect(readAgentJournalTurnOutcome(readAgentJournalTurn(settled?.body))).toBeNull() - }) - - // Any dispatched send can supersede a turn; its sender need not be the person using Orca. - it('records a turn a newer send superseded as superseded, folded as interrupted', () => { - const state = sinkState() - const translator = createClaudeJournalTranslator({ sink: state.sink }) - let observedAt = 1_000 - translator.handle({ ...userTurn('user-1'), observedAt }) - observedAt = 13_000 - translator.handle({ ...userTurn('user-2'), observedAt }) - - // Fold latest revisions and add the user rows normally written by the host. - const rows = new Map() - state.items.forEach((item, index) => { - const itemId = agentJournalItemKey(item.identity) - rows.set(itemId, { itemId, revision: index, sequence: index, observedAt, body: item.body }) - }) - const items = [...rows.values()].flatMap((row): AgentJournalRenderItem[] => { - const userItemId = readAgentJournalTurn(row.body)?.userItemId - return userItemId - ? [ - { - ...row, - itemId: userItemId, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } - }, - row - ] - : [row] - }) - const superseded = items.find((item) => readAgentJournalTurn(item.body)?.turnId === 'user-1') - expect(readAgentJournalTurn(superseded?.body)).toMatchObject({ - state: 'interrupted', - outcome: 'superseded' - }) - const settled = selectStructuredAgentSettledTurns(items).get( - readAgentJournalTurn(superseded?.body)?.userItemId ?? '' - ) - expect(settled).toMatchObject({ verdict: 'superseded', workedSeconds: 12 }) - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'interruptedAfter', duration: '12s' }) - }) -}) - -describe("a person's Stop inside a live turn", () => { - // Claude CLIs before 2.1.91 send no terminal_reason, and later ones may omit it. - const cutShort = { type: 'result', subtype: 'error_during_execution', is_error: true } - - function sinkWithStop(stop: JournalLatestStop | null) { - const state = sinkState() - const journal = createJournalReducerState('orca-session', 'epoch-1') - journal.queuePauseMarks.latestStop = stop - return { - ...state, - sink: { - ...state.sink, - journalStopDecidesTurn: (turnId: string, endedAt: number) => - personStopDecidesTurn(journal, turnId, endedAt) - } - } - } - - function stopOf( - turnId: string, - reason: JournalStopEvent['reason'] = 'user-stop' - ): JournalLatestStop { - return { sequence: 9, event: { reason, turnId, at: 1 } } - } - - function settledTurn(items: ReturnType['items'], turnId: string) { - return items - .map((item) => readAgentJournalTurn(item.body)) - .findLast((turn) => turn?.turnId === turnId && turn.state !== 'running') - } - - function providerRows(items: ReturnType['items']): number { - return items.filter( - (item) => - item.identity.provider === 'orca' && - item.identity.clientMessageId.startsWith('provider-frame:') - ).length - } - - it("leaves an error result with no terminal reason after a person's Stop of the turn to that Stop", () => { - const state = sinkWithStop(stopOf('user-1')) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - - translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - - const turn = settledTurn(state.items, 'user-1') - expect(turn).toMatchObject({ state: 'interrupted' }) - expect(turn).not.toHaveProperty('outcome') - expect(providerRows(state.items)).toBe(0) - }) - - it.each([ - ['no terminal reason', cutShort], - [ - 'a failure terminal reason', - { ...cutShort, terminal_reason: 'api_error', result: 'API Error' } - ] - ])('keeps a result with %s and no Stop a failure', (_label, frame) => { - const state = sinkWithStop(null) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - - translator.handle({ type: 'message', sessionId: 'orca-session', message: frame }) - - expect(settledTurn(state.items, 'user-1')).toMatchObject({ - state: 'completed', - outcome: 'failure' - }) - expect(providerRows(state.items)).toBe(1) - }) - - it('keeps a turn that finished during the Stop a success', () => { - const state = sinkWithStop(stopOf('user-1')) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - - translator.handle({ - type: 'message', - sessionId: 'orca-session', - message: { type: 'result', subtype: 'success', is_error: false } - }) - - expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'success' }) - }) - - it('does not carry a Stop onto the next turn', () => { - const state = sinkWithStop(stopOf('user-1')) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - translator.handle(userTurn('user-2')) - translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - translator.handle(userTurn('user-3')) - translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - - expect(settledTurn(state.items, 'user-2')).toMatchObject({ outcome: 'failure' }) - expect(settledTurn(state.items, 'user-3')).toMatchObject({ outcome: 'failure' }) - }) - - it('ends the Stop with its turn, so a result after the turn settled reads on its own', () => { - const state = sinkWithStop(stopOf('user-1')) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - translator.handle({ - type: 'message', - sessionId: 'orca-session', - message: { type: 'system', subtype: 'session_state_changed', state: 'idle' } - }) - - translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - - expect(providerRows(state.items)).toBe(1) - }) - - it.each(['host-stop', 'evict'] as const)('does not read a %s as the person asking', (reason) => { - const state = sinkWithStop(stopOf('user-1', reason)) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - - translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - - expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) - }) -}) diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts deleted file mode 100644 index 891bd170dde..00000000000 --- a/src/main/claude/claude-turn-ownership.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalItemBody } from '../../shared/agent-session/journal/agent-session-journal-types' -import { readAgentJournalTurn } from '../../shared/agent-session/lifecycle/agent-session-turn-record' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { agentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' -import { cancelClaudeStructuredTurn } from './structured/journal/claude-structured-prompt-ownership' -import { sessionFor } from './structured/claude-structured-dispatch-test-support' -import { - PROVIDER_SESSION_ID, - USER_MESSAGE, - adapterFor, - fakeClaude, - identityFor, - type FakeConnection -} from './structured/claude-structured-session-test-support' - -function journalSink(): { - sink: StructuredAgentSessionEventSink - bodies: Map -} { - const bodies = new Map() - return { - bodies, - sink: { - appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), - appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), - publish: vi.fn() - } - } -} - -function runningTurnId(bodies: Map): string | null { - for (const body of bodies.values()) { - const turn = readAgentJournalTurn(body) - if (turn?.state === 'running') { - return turn.turnId - } - } - return null -} - -async function acquiredWithJournal(claude: ReturnType): Promise<{ - adapter: ReturnType - bodies: Map - connection: FakeConnection -}> { - const { sink, bodies } = journalSink() - const adapter = adapterFor(claude) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: sink - }) - const connection = claude.connections[0] - if (!connection) { - throw new Error('expected Claude connection') - } - return { adapter, bodies, connection } -} - -function completeTurn(connection: FakeConnection, uuid: string): void { - connection.handlers.onMessage?.({ - type: 'result', - subtype: 'success', - uuid, - session_id: PROVIDER_SESSION_ID, - is_error: false, - terminal_reason: 'completed', - duration_ms: 12 - }) -} - -function providerOutput(connection: FakeConnection, uuid: string): void { - connection.handlers.onMessage?.({ - type: 'assistant', - uuid, - session_id: PROVIDER_SESSION_ID, - parent_tool_use_id: null, - message: { role: 'assistant', content: [{ type: 'text', text: 'picking this back up' }] } - }) -} - -function sessionHoldingTurn(turnId: string | null): ReturnType { - const session = sessionFor() - session.dispatchSequence = 1 - session.translator = { - handle: vi.fn(), - openTurnInLiveProviderCycle: false, - openTurnHasInputWork: false, - journalPrompts: { - resolve: vi.fn(), - handOver: () => () => {}, - cancel: () => ({ accepted: true }), - openCards: () => [][Symbol.iterator](), - whenWritten: () => undefined - }, - currentTurnId: turnId, - currentTurnScope: () => ({ kind: 'thread' }), - commandTurnId: null, - beginCommand: vi.fn(), - forgetCommand: vi.fn(), - commandInterruptRequested: vi.fn(), - flush: vi.fn(), - contextActivity: 0, - finalOutputAdmitted: true, - markContextActivity: vi.fn(), - subscribeContextUsageRequests: () => () => {}, - recordContextReport: () => {}, - modelMayHaveChanged: () => {}, - modelWritten: () => {}, - pendingStreamedBlocks: 0, - dispose: vi.fn() - } - return session -} - -/** Leave timers unadvanced to expose Stop delays from pending bookkeeping. */ -async function outcomeAtOnce(stop: () => Promise<{ cancelled: boolean }>): Promise { - vi.useFakeTimers() - try { - let outcome: unknown = 'still waiting' - void stop().then((result) => (outcome = result)) - await vi.advanceTimersByTimeAsync(0) - return outcome - } finally { - vi.useRealTimers() - } -} - -function cancellationOf( - session: ReturnType, - request: Parameters[0]['request'] -): Promise<{ cancelled: boolean }> { - return cancelClaudeStructuredTurn({ - request, - sessions: new Map([['session-1', session]]) - }) -} - -describe('Claude turn ownership', () => { - it('stops a turn the provider opened after the session already dispatched once', async () => { - const claude = fakeClaude({ replayUuid: 'echo-turn' }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - expect(runningTurnId(bodies)).toBe('echo-turn') - completeTurn(connection, 'result-1') - expect(runningTurnId(bodies)).toBeNull() - - providerOutput(connection, 'provider-turn') - expect(runningTurnId(bodies)).toBe('provider-turn') - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'provider-turn', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) - }) - - it('refuses a stale id after the owned turn settles', async () => { - const claude = fakeClaude({ replayUuid: 'echo-turn' }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - expect(runningTurnId(bodies)).toBe('echo-turn') - completeTurn(connection, 'result-1') - expect(runningTurnId(bodies)).toBeNull() - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'echo-turn', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - }) - - it('stops the running turn at once while a later send is unresolved', async () => { - const claude = fakeClaude({ replayUuid: 'echo-turn' }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - expect(runningTurnId(bodies)).toBe('echo-turn') - const sendFirst = connection.send - connection.send = async (message, beforeDispatch) => { - if (connection.sent.length > 0) { - await beforeDispatch?.() - throw new Error('input pump stopped') - } - await sendFirst(message, beforeDispatch) - } - - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-2', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'unknown' }) - - await expect( - outcomeAtOnce(() => - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'echo-turn', fence: 7 }) - ) - ).resolves.toEqual({ cancelled: true }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) - }) - - it.each([ - ['with cancel_queued', ['interrupt_cancel_queued_v1']], - ['without cancel_queued', []] - ])( - 'stops the running turn at once while its own delivery is unresolved, %s', - async (_label, capabilities) => { - const claude = fakeClaude({ replayUuid: 'echo-turn', capabilities }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - expect(runningTurnId(bodies)).toBe('echo-turn') - - await expect( - outcomeAtOnce(() => - adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'echo-turn', - fence: 7, - dispatchStatus: { state: 'unknown', recovered: false } - }) - ) - ).resolves.toEqual({ cancelled: true }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) - } - ) - - it('stops the in-memory turn at once while a retired send is unresolved', async () => { - const session = sessionHoldingTurn('turn-1') - session.retiredDispatchWaiters = [ - { - acceptsResult: false, - clientMessageId: 'client-2', - sentUuid: 'uncertain', - dispatchSequence: 1, - requestedAt: null, - replayContentKey: 'ship-it', - resolve: vi.fn(), - retired: true - } - ] - const interrupt = vi.fn().mockResolvedValue(undefined) - session.connection.interrupt = interrupt - - await expect( - outcomeAtOnce(() => - cancellationOf(session, { sessionId: 'session-1', turnId: 'turn-1', fence: 1 }) - ) - ).resolves.toEqual({ cancelled: true }) - expect(interrupt).toHaveBeenCalledOnce() - }) - - it('stops a provider-opened turn at once while the first journal submission is unresolved', async () => { - const claude = fakeClaude({ replayUuid: null }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - providerOutput(connection, 'provider-turn') - expect(runningTurnId(bodies)).toBe('provider-turn') - - await expect( - outcomeAtOnce(() => - adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'provider-turn', - fence: 7, - dispatchStatus: { state: 'pending', recovered: false } - }) - ) - ).resolves.toEqual({ cancelled: true }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) - }) - - it('still stops an echo-opened turn', async () => { - const claude = fakeClaude({ replayUuid: 'echo-turn' }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - expect(runningTurnId(bodies)).toBe('echo-turn') - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'echo-turn', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) - }) - - it('admits a Stop for the published turn while the adapter already holds an undrained one', async () => { - const session = sessionHoldingTurn('turn-undrained') - const interrupt = vi.fn().mockResolvedValue(undefined) - session.connection.interrupt = interrupt - - await expect( - cancellationOf(session, { - sessionId: 'session-1', - turnId: 'turn-shown', - fence: 1, - resolveLiveTurnId: () => 'turn-shown' - }) - ).resolves.toEqual({ cancelled: true }) - expect(interrupt).toHaveBeenCalledOnce() - }) - - it('admits a Stop for the live turn while the journal has not drained its row', async () => { - const session = sessionHoldingTurn('turn-live') - const interrupt = vi.fn().mockResolvedValue(undefined) - session.connection.interrupt = interrupt - - await expect( - cancellationOf(session, { - sessionId: 'session-1', - turnId: 'turn-live', - fence: 1, - resolveLiveTurnId: () => null - }) - ).resolves.toEqual({ cancelled: true }) - expect(interrupt).toHaveBeenCalledOnce() - }) - - it('refuses a Stop the adapter still holds once the journal published a newer turn', async () => { - const session = sessionHoldingTurn('turn-stale') - const interrupt = vi.fn().mockResolvedValue(undefined) - session.connection.interrupt = interrupt - - await expect( - cancellationOf(session, { - sessionId: 'session-1', - turnId: 'turn-stale', - fence: 1, - resolveLiveTurnId: () => 'turn-newer' - }) - ).resolves.toEqual({ cancelled: false }) - expect(interrupt).not.toHaveBeenCalled() - }) - - it('refuses a Stop for an ended turn at once while a later send is unresolved', async () => { - const session = sessionHoldingTurn('turn-next') - const interrupt = vi.fn().mockResolvedValue(undefined) - session.connection.interrupt = interrupt - - await expect( - outcomeAtOnce(() => - cancellationOf(session, { - sessionId: 'session-1', - turnId: 'turn-shown', - fence: 1, - dispatchStatus: { state: 'unknown', recovered: false }, - resolveLiveTurnId: () => 'turn-next' - }) - ) - ).resolves.toEqual({ cancelled: false }) - expect(interrupt).not.toHaveBeenCalled() - }) - - it('refuses a stale turn id once the provider opened a newer turn', async () => { - const claude = fakeClaude({ replayUuid: 'echo-turn' }) - const { adapter, bodies, connection } = await acquiredWithJournal(claude) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - completeTurn(connection, 'result-1') - providerOutput(connection, 'provider-turn') - expect(runningTurnId(bodies)).toBe('provider-turn') - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'echo-turn', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'not-a-turn', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - }) -}) diff --git a/src/main/claude/claude-turn-resumption.test.ts b/src/main/claude/claude-turn-resumption.test.ts deleted file mode 100644 index 27f1e8c834d..00000000000 --- a/src/main/claude/claude-turn-resumption.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -// Captured 2026-09-13 (962e6f25…/3d214e6f…): provider-resumed work incorrectly stayed idle. - -import { describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../shared/agent-session/journal/agent-session-journal-types' -import type { AgentJournalItemIdentity } from '../../shared/agent-session/journal/agent-session-journal-identity' -import { agentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' -import { - legacyAgentJournalTurnStatusBody, - readAgentJournalTurn -} from '../../shared/agent-session/lifecycle/agent-session-turn-record' -import { selectStructuredAgentSettledTurns } from '../../shared/agent-session/view/structured-agent-session-turn-timing' -import { hasUnansweredStructuredAgentSessionDispatch } from '../../shared/agent-session/requests/structured-agent-session-unanswered-dispatch' -import { - projectStructuredAgentSessionStatus, - projectStructuredAgentSessionStatusSummary -} from '../../shared/agent-session/view/structured-agent-session-projection' -import { statusStructuredAgentSessionToolCall } from '../../shared/agent-session/view/structured-agent-session-live-turn' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation' - -const SESSION = 'claude-session' - -function harness() { - const appended: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] - const sink: StructuredAgentSessionEventSink = { - appendItem: (identity, body) => appended.push({ identity, body }), - appendTombstone: () => {}, - publish: vi.fn() - } - const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) - const items = (): AgentJournalRenderItem[] => { - const byKey = new Map() - appended.forEach(({ identity, body }, index) => { - const key = agentJournalItemKey(identity) - const existing = byKey.get(key) - byKey.set(key, { - itemId: key, - revision: (existing?.revision ?? 0) + 1, - body, - sequence: existing?.sequence ?? index, - observedAt: index - }) - }) - return [...byKey.values()].sort((a, b) => a.sequence - b.sequence) - } - return { translator, items, appended } -} - -function frame( - 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: SESSION, - parent_tool_use_id: parentToolUseId, - message: { role: type, content } - } - } -} - -/** Captured provider wake-up has no dispatch waiter, so it cannot open a turn. */ -function taskNotification(uuid: string) { - return { - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'user', - uuid, - session_id: SESSION, - parent_tool_use_id: null, - message: { - role: 'user', - content: [{ type: 'text', text: 'bfnmj08v6' }] - } - } - } -} - -function textDelta(uuid: string, messageId: string, text: string) { - return { - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'stream_event', - uuid, - session_id: SESSION, - parent_tool_use_id: null, - event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }, - message: { id: messageId } - } - } -} - -function streamMessageStart(uuid: string, parentToolUseId: string | null = null) { - return { - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'stream_event', - uuid, - session_id: SESSION, - parent_tool_use_id: parentToolUseId, - event: { type: 'message_start', message: { id: `msg-${uuid}`, role: 'assistant' } } - } - } -} - -function result(uuid: string, parentToolUseId: string | null = null, durationMs = 322_937) { - return { - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'result', - subtype: 'success', - uuid, - session_id: SESSION, - parent_tool_use_id: parentToolUseId, - duration_ms: durationMs - } - } -} - -function projected(items: readonly AgentJournalRenderItem[]): string { - // No pending dispatch can mask the false-idle regression. - expect(hasUnansweredStructuredAgentSessionDispatch([], null)).toBe(false) - return projectStructuredAgentSessionStatus(items, [], null) -} - -describe('a Claude turn the provider resumed on its own', () => { - it('reports working while the agent runs tool calls after a result settled the turn', () => { - const { translator, items } = harness() - - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - expect(projected(items())).toBe('working') - - translator.handle(result('r1')) - expect(projected(items())).toBe('idle') - - translator.handle(taskNotification('n1')) - translator.handle(frame('assistant', 'a1', [{ type: 'text', text: 'Back on it.' }])) - expect(projected(items())).toBe('working') - - translator.handle( - frame('assistant', 'a2', [ - { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'rg foo' } } - ]) - ) - expect(projected(items())).toBe('working') - - translator.handle(result('r2')) - expect(projected(items())).toBe('idle') - }) - - it('gives the resumed turn its own record, anchored away from the preceding user row', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(result('r1')) - translator.handle(frame('assistant', 'a1', [{ type: 'text', text: 'Back on it.' }])) - - const turns = items().flatMap((item) => { - const turn = readAgentJournalTurn(item.body) - return turn ? [turn] : [] - }) - expect(turns.map((turn) => turn.state)).toEqual(['completed', 'running']) - expect(turns[1]?.turnId).toBe('a1') - expect(turns[1]?.userItemId).toBe('legacy:claude:claude-session:turn-lifecycle%3Aa1') - }) - - it('does not replace the preceding prompt timing with provider-resumed work', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(result('r1', null, 1_000)) - translator.handle(frame('assistant', 'a1', [{ type: 'text', text: 'Back on it.' }])) - translator.handle(result('r2', null, 9_000)) - - const translatedItems = items() - const originalTurn = translatedItems - .map((item) => readAgentJournalTurn(item.body)) - .find((turn) => turn?.turnId === 'u1') - expect(originalTurn?.userItemId).toBeDefined() - if (!originalTurn?.userItemId) { - throw new Error('expected the original turn to name its user row') - } - const userItem: AgentJournalRenderItem = { - itemId: originalTurn.userItemId, - revision: 1, - sequence: -1, - observedAt: 0, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } - } - const currentItems = [userItem, ...translatedItems] - expect(selectStructuredAgentSettledTurns(currentItems).get(userItem.itemId)).toMatchObject({ - workedSeconds: 1 - }) - - const legacyItems = currentItems.map((item) => { - const turn = readAgentJournalTurn(item.body) - return item.body.kind === 'turn' && turn - ? { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId) } - : item - }) - expect(selectStructuredAgentSettledTurns(legacyItems).get(userItem.itemId)).toMatchObject({ - workedSeconds: 1 - }) - }) - - it('leaves a settled turn settled when only a subagent is still producing', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(result('r1')) - - translator.handle(streamMessageStart('child-start', 'toolu_parent')) - expect(projected(items())).toBe('idle') - }) - - it('does not reopen a turn that is already running', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(frame('assistant', 'a1', [{ type: 'text', text: 'one' }])) - translator.handle(frame('assistant', 'a2', [{ type: 'text', text: 'two' }])) - - const running = items().filter((item) => readAgentJournalTurn(item.body)?.state === 'running') - expect(running).toHaveLength(1) - expect(readAgentJournalTurn(running[0]!.body)?.turnId).toBe('u1') - expect(projected(items())).toBe('working') - }) - - it('reports the first tool call of a resumed turn as the live tool', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - // Prose makes the original session listable. - translator.handle(frame('assistant', 'a0', [{ type: 'text', text: 'Launched it.' }])) - translator.handle(result('r1')) - - // The turn record must precede its first tool row for turn-scoped readers. - translator.handle( - frame('assistant', 'a1', [ - { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'rg foo' } } - ]) - ) - - expect(projected(items())).toBe('working') - expect(statusStructuredAgentSessionToolCall(items())?.name).toBe('Bash') - expect(projectStructuredAgentSessionStatusSummary(items(), [], null).toolName).toBe('Bash') - }) - - it('leaves the turn running when a nested result settles a child', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(frame('assistant', 'a0', [{ type: 'text', text: 'working on it' }])) - - // Synthetic nested result: no live capture; nested output must not settle the root turn. - translator.handle(result('r-child', 'toolu_parent')) - expect(projected(items())).toBe('working') - - translator.handle(result('r-root')) - expect(projected(items())).toBe('idle') - }) - - it('never opens a turn from a frame that arrives after the session ended', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(frame('assistant', 'a0', [{ type: 'text', text: 'on it' }])) - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'exit', observedAt: 1 }) - expect(projected(items())).toBe('idle') - - translator.handle(streamMessageStart('late-start')) - expect(projected(items())).toBe('idle') - }) - - it('does not let provider chatter resume a turn the provider failed', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(frame('assistant', 'a0', [{ type: 'text', text: 'on it' }])) - translator.handle({ - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'result', - subtype: 'error', - uuid: 'r-fail', - session_id: SESSION, - parent_tool_use_id: null, - is_error: true - } - }) - expect(projected(items())).toBe('idle') - - translator.handle(frame('assistant', 'a1', [{ type: 'text', text: 'still talking' }])) - expect(projected(items())).toBe('idle') - - translator.handle(frame('user', 'u2', [{ type: 'text', text: 'again' }])) - expect(projected(items())).toBe('working') - }) - - it('reports working from the first streamed delta of a resumed turn', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(frame('assistant', 'a0', [{ type: 'text', text: 'on it' }])) - translator.handle(result('r1')) - expect(projected(items())).toBe('idle') - - translator.handle(textDelta('d1', 'msg-1', 'Back ')) - translator.handle(textDelta('d2', 'msg-1', 'on it.')) - expect(projected(items())).toBe('working') - }) - - it('opens before a resumed stream produces its first content delta', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(result('r1')) - - translator.handle(streamMessageStart('message-start-1')) - - expect(projected(items())).toBe('working') - expect(readAgentJournalTurn(items().at(-1)?.body)?.turnId).toBe('message-start-1') - expect(items().some((item) => item.body.kind === 'status')).toBe(false) - }) - - it('opens before journaling substantive fallback output', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(result('r1')) - - translator.handle( - frame('assistant', 'a1', [{ type: 'future_content', message: 'new provider output' }]) - ) - - const resumed = items().slice(-2) - expect(readAgentJournalTurn(resumed[0]?.body)?.state).toBe('running') - expect(resumed[1]?.body).toMatchObject({ - kind: 'status', - providerFrame: { kind: 'message:assistant:content:future_content' } - }) - expect(projected(items())).toBe('working') - }) - - it('does not open a turn for an empty assistant placeholder', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(result('r1')) - - translator.handle(frame('assistant', 'empty-1', [])) - - expect(projected(items())).toBe('idle') - expect(items().at(-1)?.body).toMatchObject({ - kind: 'status', - providerFrame: { kind: 'message:assistant:empty' } - }) - }) - - it('still reports a nested result failure even though it settles no turn', () => { - const { translator, items, appended } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(frame('assistant', 'a0', [{ type: 'text', text: 'on it' }])) - const before = appended.length - translator.handle({ - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'result', - subtype: 'error', - uuid: 'r-child-fail', - session_id: SESSION, - parent_tool_use_id: 'toolu_parent', - is_error: true, - result: 'child blew up' - } - }) - expect(projected(items())).toBe('working') - expect(appended.length).toBeGreaterThan(before) - }) - - it('keeps the failure latch set when a later root result succeeds', () => { - const { translator, items } = harness() - translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) - translator.handle(frame('assistant', 'a0', [{ type: 'text', text: 'on it' }])) - translator.handle({ - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'result', - subtype: 'error', - uuid: 'r-fail', - session_id: SESSION, - parent_tool_use_id: null, - is_error: true - } - }) - translator.handle(result('r-late-ok')) - translator.handle(frame('assistant', 'a1', [{ type: 'text', text: 'still talking' }])) - expect(projected(items())).toBe('idle') - }) -}) diff --git a/src/main/claude/claude-usage-limits.test.ts b/src/main/claude/claude-usage-limits.test.ts deleted file mode 100644 index 8e165cb5aa7..00000000000 --- a/src/main/claude/claude-usage-limits.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { latestNativeChatUsageLimitCut } from '../../shared/native-chat/native-chat-usage-limit-cut' -import { agentJournalSubmissionKey } from '../../shared/agent-session/journal/agent-session-journal-item-key' -import { - adapterFor, - fakeClaude, - identityFor, - PROVIDER_SESSION_ID, - USER_MESSAGE -} from './structured/claude-structured-session-test-support' -import { assistantFrame, frame, setup, userFrame } from './claude-context-usage-test-support' - -// Seconds, as Claude sends them; two hours out so no reset passes mid-test. -const RESETS_AT_S = Math.floor(Date.now() / 1000) + 2 * 3600 - -const rateLimitEvent = (status: string, at: number) => - frame( - { - type: 'rate_limit_event', - uuid: `rate-${at}`, - rate_limit_info: { status, rateLimitType: 'five_hour', resetsAt: RESETS_AT_S } - }, - at - ) - -const refusedFrame = (uuid: string, at: number, text: string) => - frame( - { - type: 'assistant', - uuid, - user_message_uuid: `turn-${uuid}`, - error: 'rate_limit', - is_api_error_message: true, - message: { role: 'assistant', content: [{ type: 'text', text }] } - }, - at - ) - -const failedResult = (uuid: string, at: number, text: string) => - frame( - { - type: 'result', - subtype: 'success', - is_error: true, - result: text, - uuid: `result-${uuid}`, - user_message_uuid: `turn-${uuid}`, - duration_ms: 10 - }, - at - ) - -const LIMIT_TEXT = "You've hit your limit · resets 3pm" - -function statusRows(t: ReturnType) { - return t.items().flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) -} - -function messageTexts(t: ReturnType): string[] { - return t - .items() - .flatMap((item) => - item.body.kind === 'message' - ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) - : [] - ) -} - -describe('a Claude usage limit', () => { - it.each([false, true])( - 'keeps one limit row across a result and idle (quota first %s)', - (quotaFirst) => { - const t = setup() - if (!quotaFirst) { - t.handle(userFrame('turn-a', 1_000)) - } - t.handle(rateLimitEvent('rejected', 1_100)) - if (!quotaFirst) { - t.handle( - frame( - { ...assistantFrame('first', 1_150, 20).message, user_message_uuid: 'turn-a' }, - 1_150 - ) - ) - } - t.handle( - frame( - { - ...refusedFrame('a', 1_200, LIMIT_TEXT).message, - user_message_uuid: undefined, - api_error: 'usage_limit_reached' - }, - 1_200 - ) - ) - t.handle(failedResult('a', 1_300, 'Request failed')) - t.handle(frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1_301)) - // The CLI throttles an unchanged rejection: the next turn carries none. - t.handle(userFrame('turn-b', 2_000)) - t.handle(refusedFrame('b', 2_200, LIMIT_TEXT)) - t.handle(failedResult('b', 2_300, LIMIT_TEXT)) - - const limitRow = { - kind: 'status', - tone: 'warning', - text: 'Claude hit its session limit.', - failure: { - kind: 'usageLimitReached', - usageLimit: { window: 'session', resetsAt: RESETS_AT_S * 1000 }, - detail: { text: LIMIT_TEXT, audience: 'person' } - } - } - expect(statusRows(t)).toEqual([ - { - ...limitRow, - failure: { - ...limitRow.failure, - detail: { text: `${LIMIT_TEXT}\nRequest failed`, audience: 'person' } - } - }, - limitRow - ]) - expect(messageTexts(t)).not.toContain(LIMIT_TEXT) - t.translator.dispose() - } - ) - - it('keeps a removed-media notice before later work and its usage-limit warning', () => { - const t = setup({ deferred: true }) - const removed = 'API Error: the image could not be processed and was removed' - t.handle(userFrame('turn-a', 1000)) - t.handle( - frame( - { - ...refusedFrame('media', 1100, removed).message, - api_error: 'media_removed', - error: undefined, - user_message_uuid: 'turn-a' - }, - 1100 - ) - ) - t.handle(assistantFrame('answer', 1200, 20)) - t.handle( - frame( - { ...refusedFrame('a', 1300, LIMIT_TEXT).message, api_error: 'usage_limit_reached' }, - 1300 - ) - ) - t.handle(failedResult('a', 1400, LIMIT_TEXT)) - t.flush() - const visible = t - .items() - .flatMap(({ body }) => - body.kind === 'message' && body.role === 'assistant' - ? body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) - : body.kind === 'status' && body.failure?.kind === 'usageLimitReached' - ? ['quota'] - : [] - ) - expect(visible).toEqual([removed, 'answer', 'quota']) - t.translator.dispose() - }) - - it('keeps typed other causes and replacement turns separate from the account snapshot', () => { - const t = setup() - t.handle(userFrame('turn-limited', 1_000)) - t.handle(rateLimitEvent('rejected', 1_100)) - t.handle(refusedFrame('limited', 1_200, LIMIT_TEXT)) - // No result: a replacement turn must not inherit the previous refusal. - t.handle(userFrame('turn-credits', 2_000)) - t.handle( - frame( - { - ...refusedFrame('credits', 2_100, 'This model requires usage credits').message, - api_error: 'model_requires_usage_credits' - }, - 2_100 - ) - ) - t.handle( - frame( - { - ...failedResult('credits', 2_200, 'This model requires usage credits').message, - terminal_reason: 'api_error', - api_error_status: 429 - }, - 2_200 - ) - ) - expect(statusRows(t).filter((row) => row.failure?.kind === 'usageLimitReached')).toHaveLength(1) - expect(statusRows(t).some((row) => row.text.includes('requires usage credits'))).toBe(true) - t.handle(userFrame('turn-context', 2300)) - t.handle( - frame( - { - ...refusedFrame('context', 2400, 'Prompt is too long').message, - error: 'invalid_request' - }, - 2400 - ) - ) - t.handle( - frame( - { - ...failedResult('context', 2500, 'Prompt is too long').message, - terminal_reason: 'blocking_limit', - api_error_status: null - }, - 2500 - ) - ) - expect(statusRows(t).at(-1)).toMatchObject({ tone: 'error', text: 'Prompt is too long' }) - t.translator.dispose() - }) - - it('keeps an explicit quota cause without metadata and expires snapshot inference', () => { - const clock = vi.spyOn(Date, 'now').mockReturnValue(RESETS_AT_S * 1000 - 1000) - try { - const t = setup() - t.handle(userFrame('turn-typed', 1000)) - t.handle( - frame( - { ...refusedFrame('typed', 1100, LIMIT_TEXT).message, api_error: 'usage_limit_reached' }, - 1100 - ) - ) - t.handle(failedResult('typed', 1200, LIMIT_TEXT)) - t.handle(rateLimitEvent('rejected', 1300)) - clock.mockReturnValue(RESETS_AT_S * 1000 + 1000) - t.handle(userFrame('turn-expired', 2000)) - t.handle(refusedFrame('expired', 2100, 'Request rejected')) - t.handle(failedResult('expired', 2200, 'Request rejected')) - const limits = statusRows(t).filter((row) => row.failure?.kind === 'usageLimitReached') - expect(limits).toHaveLength(1) - expect(limits[0]?.failure?.usageLimit).toBeUndefined() - t.translator.dispose() - } finally { - clock.mockRestore() - } - }) - - it('leaves a throttle once the limit allows requests again', () => { - const t = setup() - t.handle(rateLimitEvent('rejected', 1_100)) - t.handle(rateLimitEvent('allowed_warning', 1_500)) - t.handle(userFrame('turn-a', 2_000)) - t.handle(refusedFrame('a', 2_200, 'API Error: Request rejected (429)')) - t.handle(failedResult('a', 2_300, 'API Error: Request rejected (429)')) - - expect(statusRows(t).map((row) => row.failure?.kind)).not.toContain('usageLimitReached') - expect(statusRows(t)).toContainEqual( - expect.objectContaining({ tone: 'error', text: 'API Error: Request rejected (429)' }) - ) - t.translator.dispose() - }) -}) - -const quota = (uuid: string, stamp?: string) => - frame( - { - ...refusedFrame(uuid, 1100, LIMIT_TEXT).message, - user_message_uuid: stamp, - api_error: 'usage_limit_reached' - }, - 1100 - ) - -it.each(['idle', 'exit'] as const)( - 'keeps quota failure and Continue through a deferred resultless %s', - (ending) => { - const t = setup({ deferred: true }) - t.handle(userFrame('owner', 1000)) - t.handle(quota('refusal', 'owner')) - if (ending === 'idle') { - t.handle(frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1200)) - } else { - t.handle({ - type: 'ended', - sessionId: 'orca-session', - reason: 'Provider exited', - observedAt: 1200 - }) - } - t.flush() - expect(t.turnRow('owner')?.body).toMatchObject({ - state: ending === 'idle' ? 'completed' : 'interrupted', - outcome: 'failure' - }) - expect(latestNativeChatUsageLimitCut(t.items(), [])).not.toBeNull() - t.translator.dispose() - } -) - -it.each(['before', 'after', 'child', 'ordinary', 'success', 'other', 'superseded'] as const)( - 'preserves %s precedence over resultless quota inference', - (mode) => { - const t = setup({ deferred: true }) - t.handle(userFrame('owner', 1000)) - if (mode !== 'ordinary') { - t.handle( - frame( - { - ...quota('refusal', 'owner').message, - ...(mode === 'child' ? { parent_tool_use_id: 'child-tool' } : {}) - }, - 1100 - ) - ) - } - if (mode === 'other') { - t.handle( - frame( - { - ...refusedFrame('other', 1150, 'Requires credits').message, - api_error: 'model_requires_usage_credits', - user_message_uuid: 'owner' - }, - 1150 - ) - ) - } - if (mode === 'before') { - t.queueStop('owner', 1150) - expect(t.sink.journalStopDecidesTurn?.('owner', 1200)).toBe(false) - } - if (mode === 'success') { - t.handle(frame({ type: 'result', subtype: 'success', is_error: false, result: 'done' }, 1200)) - } else if (mode === 'superseded') { - t.handle(userFrame('newer', 1200)) - } else if (mode === 'before') { - t.handle(frame({ type: 'system', subtype: 'session_state_changed', state: 'idle' }, 1200)) - } else { - t.handle({ - type: 'ended', - sessionId: 'orca-session', - reason: 'Provider exited', - observedAt: 1200 - }) - } - if (mode === 'after') { - t.queueStop('owner', 1300) - } - t.flush() - const outcome = - mode === 'before' - ? 'cancellation' - : mode === 'after' - ? 'failure' - : mode === 'success' - ? 'success' - : mode === 'superseded' - ? 'superseded' - : undefined - const body = t.turnRow('owner')?.body - expect(body?.kind === 'turn' ? body.outcome : null).toEqual(outcome) - expect(latestNativeChatUsageLimitCut(t.items(), []) !== null).toBe(mode === 'after') - t.translator.dispose() - } -) - -it('retains a newly accepted pre-echo failure after acquisition has consumed its exact waiter', async () => { - const t = setup() - t.translator.dispose() - const claude = fakeClaude({ replayUuid: null }) - const adapter = adapterFor(claude) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'quota-test', - events: t.sink - }) - const connection = claude.connections[0]! - const emit = (message: Record) => - connection.handlers.onMessage?.({ - parent_tool_use_id: null, - ...message, - session_id: PROVIDER_SESSION_ID - }) - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'first', - body: USER_MESSAGE, - fence: 7 - }) - emit({ ...connection.sent[0], uuid: 'owner' }) - emit(quota('refusal', 'owner').message) - emit({ ...failedResult('owner', 1200, LIMIT_TEXT).message, user_message_uuid: 'owner' }) - t.sink.appendItem({ provider: 'orca', clientMessageId: 'new-send' }, USER_MESSAGE, { - turnScope: { kind: 'thread' } - }) - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'new-send', - body: USER_MESSAGE, - fence: 7 - }) - const sent = connection.sent.at(-1)! - emit({ - type: 'result', - uuid: 'new-result', - user_message_uuid: sent.uuid, - subtype: 'error_during_execution', - is_error: true, - result: 'Fresh request rejected' - }) - expect(t.items().some((item) => item.itemId === agentJournalSubmissionKey('new-send'))).toBe(true) - expect(statusRows(t).some((row) => row.text.includes('Fresh request rejected'))).toBe(true) - await adapter.closeSession('session-1') -}) diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts deleted file mode 100644 index c06354cabea..00000000000 --- a/src/main/claude/compact-status-registration.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { normalizeHookPayload } from '../../shared/agent-hook-listener/normalization/normalize-hook-payload' -import { - createHookListenerState, - seedLegacyAgentStatusForTests, - type HookListenerState -} from '../../shared/agent-hook-listener/listener-state' -import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import { makePaneKey } from '../../shared/workspace-layout/stable-pane-id' -import { CLAUDE_HOOK_EVENT_FIRST_VERSIONS } from './claude-hook-event-versions' -import { CLAUDE_EVENTS, getClaudeManagedHookPlan } from './claude-managed-hook-events' -import { applyManagedHooks } from './hook-settings' - -const PANE_KEY = makePaneKey('compact-registration', '11111111-1111-4111-8111-111111111111') -const TURN_PROMPT_ID = '22222222-2222-4222-8222-222222222222' -const COMPACT_PROMPT_ID = '33333333-3333-4333-8333-333333333333' - -const REGISTERED_EVENT_NAMES = new Set(CLAUDE_EVENTS.map((event) => event.eventName)) - -/** Gate delivery on registration so tests cannot exercise hooks Claude never emits to Orca. */ -function deliverIfRegistered( - state: HookListenerState, - payload: Record -): AgentHookEventPayload | null { - if (!REGISTERED_EVENT_NAMES.has(payload.hook_event_name as string)) { - return null - } - const event = normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') - if (event) { - seedLegacyAgentStatusForTests(state, event) - } - return event -} - -function hook(name: string, promptId: string, extra: Record = {}) { - return { hook_event_name: name, prompt_id: promptId, session_id: 'session-a', ...extra } -} - -/** Manual /compact sequence captured from a PTY-driven Claude Code 2.1.227 session. */ -function successfulManualCompact(state: HookListenerState) { - deliverIfRegistered(state, hook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) - deliverIfRegistered( - state, - hook('SubagentStop', COMPACT_PROMPT_ID, { - agent_id: 'a75b38b59774e1f31', - agent_type: '', - background_tasks: [], - session_crons: [] - }) - ) - deliverIfRegistered(state, hook('SessionStart', COMPACT_PROMPT_ID, { source: 'compact' })) - return deliverIfRegistered(state, hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) -} - -function startTurn(state: HookListenerState) { - deliverIfRegistered(state, hook('UserPromptSubmit', TURN_PROMPT_ID, { prompt: 'do the thing' })) -} - -/** Reproduce a hydrated working row, optionally with a child known only from a disk snapshot. */ -function hydrateStuckRow( - state: HookListenerState, - options: { withRestoredChild?: boolean; providerSession?: boolean } = {} -): void { - const subagents = options.withRestoredChild - ? [{ id: 'child-1', state: 'working' as const, startedAt: 0, agentType: 'general' }] - : undefined - const hydrated = { - paneKey: PANE_KEY, - source: 'claude', - connectionId: 'a-previous-connection', - hookEventName: 'UserPromptSubmit', - providerPromptId: TURN_PROMPT_ID, - ...(options.providerSession === false - ? {} - : { providerSession: { key: 'session_id', id: 'session-a' } }), - restoredUnconfirmed: true, - payload: { - state: 'working', - prompt: 'work before the restart', - agentType: 'claude', - ...(subagents ? { subagents } : {}) - } - } as unknown as AgentHookEventPayload - seedLegacyAgentStatusForTests(state, hydrated) - if (subagents) { - seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, subagents) - } -} - -describe('Claude compact hook registration', () => { - it('writes PostCompact, and no PreCompact, into the settings Claude actually reads', () => { - const written = applyManagedHooks( - { hooks: {} }, - () => ({ type: 'command', command: 'orca-claude-hook' }), - 'claude-hook.sh', - getClaudeManagedHookPlan(CLAUDE_HOOK_EVENT_FIRST_VERSIONS.PostCompact) - ) - const postCompact = written.hooks?.PostCompact ?? [] - expect( - postCompact.some((definition) => - (definition.hooks ?? []).some((entry) => entry.command === 'orca-claude-hook') - ) - ).toBe(true) - expect(written.hooks?.PreCompact).toBeUndefined() - }) -}) - -describe('STA-2915: a manual compact clears the pane', () => { - it('ends a stuck working row as a silent done', () => { - const state = createHookListenerState() - startTurn(state) - expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('working') - - const completion = successfulManualCompact(state) - - expect(completion?.payload.state).toBe('done') - // Session boundaries must not trigger turn-completion automations, unread counts, or notifications. - expect(completion?.payload.sessionBoundary).toBe(true) - }) - - it('keeps the summarizer SubagentStop from being the thing that resolves the pane', () => { - const state = createHookListenerState() - startTurn(state) - const republished = deliverIfRegistered( - state, - hook('SubagentStop', COMPACT_PROMPT_ID, { - agent_id: 'a75b38b59774e1f31', - agent_type: '', - background_tasks: [], - session_crons: [] - }) - ) - expect(republished?.payload.state).toBe('working') - }) -}) - -describe('STA-4613: an aborted compact must not strand the pane', () => { - it('does not map PreCompact even when it is delivered past the registration gate', () => { - const state = createHookListenerState() - startTurn(state) - deliverIfRegistered(state, hook('Stop', TURN_PROMPT_ID)) - - const event = normalizeHookPayload( - state, - 'claude', - { - paneKey: PANE_KEY, - payload: hook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) - }, - 'production' - ) - - expect(event).toBeNull() - }) -}) - -describe('an auto compact claims nothing', () => { - it('leaves the in-flight turn alone and lets its own Stop close it', () => { - const state = createHookListenerState() - startTurn(state) - - const autoCompact = deliverIfRegistered( - state, - hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'auto' }) - ) - - expect(autoCompact).toBeNull() - expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('working') - - deliverIfRegistered(state, hook('Stop', TURN_PROMPT_ID)) - expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('done') - }) -}) - -describe('STA-2915: the pane the ticket actually reports', () => { - it('clears a row stuck behind a child that exists only as a disk snapshot', () => { - const state = createHookListenerState() - hydrateStuckRow(state, { withRestoredChild: true }) - - deliverIfRegistered(state, hook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) - // Keep the hydrated row as owner so completion exercises restored-state handling. - const summarizer = deliverIfRegistered( - state, - hook('SubagentStop', COMPACT_PROMPT_ID, { agent_id: 'a75b38b59774e1f31', agent_type: '' }) - ) - expect(summarizer).toBeNull() - deliverIfRegistered(state, hook('SessionStart', COMPACT_PROMPT_ID, { source: 'compact' })) - - const completion = deliverIfRegistered( - state, - hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) - ) - - // A restored child snapshot does not prove live work at an idle manual-compaction boundary. - expect(completion?.payload.state).toBe('done') - expect(completion?.payload.sessionBoundary).toBe(true) - expect(completion?.payload.subagents ?? []).toEqual([]) - }) - - it('leaves a pane held by a RUNTIME child at working, and republishes nothing', () => { - const state = createHookListenerState() - startTurn(state) - deliverIfRegistered(state, hook('SubagentStart', TURN_PROMPT_ID, { agent_id: 'child-live' })) - const before = state.lastStatusByPaneKey.get(PANE_KEY) - - const completion = deliverIfRegistered( - state, - hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) - ) - - // Republishing would clear restoredUnconfirmed and reset staleness for work the compact never observed. - expect(completion).toBeNull() - expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(before) - expect(before?.payload.state).toBe('working') - }) - - it('leaves a pane held by an active session cron at working, and republishes nothing', () => { - const state = createHookListenerState() - startTurn(state) - deliverIfRegistered(state, hook('Stop', TURN_PROMPT_ID, { session_crons: [{ id: 'cron-1' }] })) - const before = state.lastStatusByPaneKey.get(PANE_KEY) - expect(before?.payload.state).toBe('working') - - expect( - deliverIfRegistered(state, hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) - ).toBeNull() - expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(before) - }) -}) - -describe('compact completion guards', () => { - it('does not resurrect a pane that was retired before the completion arrived', () => { - const state = createHookListenerState() - expect(successfulManualCompact(state)).toBeNull() - expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBeUndefined() - }) - - it('clears a stuck row restored from disk after a restart, despite a stale connection id', () => { - const state = createHookListenerState() - hydrateStuckRow(state) - // Deliver completion alone; a summarizer could replace the hydrated row and bypass this branch. - const completion = deliverIfRegistered( - state, - hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) - ) - - expect(completion?.payload.state).toBe('done') - expect(completion?.payload.sessionBoundary).toBe(true) - }) - - it('clears a restored row that predates provider-session persistence', () => { - const state = createHookListenerState() - hydrateStuckRow(state, { providerSession: false }) - expect(state.lastStatusByPaneKey.get(PANE_KEY)?.providerSession).toBeUndefined() - - const completion = deliverIfRegistered( - state, - hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) - ) - - expect(completion?.payload.state).toBe('done') - }) - - it('does not clear a live turn owned by a different Claude session', () => { - const state = createHookListenerState() - startTurn(state) - - const foreign = normalizeHookPayload( - state, - 'claude', - { - paneKey: PANE_KEY, - payload: { - hook_event_name: 'PostCompact', - prompt_id: COMPACT_PROMPT_ID, - session_id: 'a-different-session', - trigger: 'manual' - } - }, - 'production' - ) - - expect(foreign).toBeNull() - expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('working') - }) - - it('rejects a compact completion with no provider prompt id', () => { - const state = createHookListenerState() - startTurn(state) - - const event = normalizeHookPayload( - state, - 'claude', - { - paneKey: PANE_KEY, - payload: { hook_event_name: 'PostCompact', session_id: 'session-a', trigger: 'manual' } - }, - 'production' - ) - - expect(event).toBeNull() - }) - - it('applies a compact completion once, so a duplicate cannot refresh the row', () => { - const state = createHookListenerState() - startTurn(state) - expect(successfulManualCompact(state)?.payload.state).toBe('done') - - const duplicate = normalizeHookPayload( - state, - 'claude', - { - paneKey: PANE_KEY, - payload: hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }) - }, - 'production' - ) - - expect(duplicate).toBeNull() - }) -}) diff --git a/src/main/claude/statusline-script.test.ts b/src/main/claude/statusline-script.test.ts index 8515873e23e..73b56bf650e 100644 --- a/src/main/claude/statusline-script.test.ts +++ b/src/main/claude/statusline-script.test.ts @@ -3,128 +3,14 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { CLAUDE_STATUSLINE_MIN_POST_INTERVAL_SECONDS } from '../../shared/claude-statusline-rate-limits' import { getManagedStatusLineScript } from './statusline-script' const ORIGINAL_PLATFORM = process.platform -function stubPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) -} - afterEach(() => { Object.defineProperty(process, 'platform', { configurable: true, value: ORIGINAL_PLATFORM }) }) -describe('getManagedStatusLineScript (posix)', () => { - it('guards on rate_limits before sourcing the endpoint or spawning curl', () => { - stubPlatform('darwin') - const script = getManagedStatusLineScript('local') - expect(script).toBe(getManagedStatusLineScript('posix')) - const guardIndex = script.indexOf('*\'"rate_limits"\'*') - const endpointIndex = script.indexOf('ORCA_AGENT_HOOK_ENDPOINT') - const curlIndex = script.indexOf('curl -sS') - expect(guardIndex).toBeGreaterThan(-1) - expect(guardIndex).toBeLessThan(endpointIndex) - expect(endpointIndex).toBeLessThan(curlIndex) - expect(script).toContain('/statusline/claude') - expect(script).toContain('--data-urlencode "payload@-"') - expect(script).toContain('| LC_ALL= LC_NUMERIC=C curl -sS') - expect(script).not.toMatch(/\|\s+curl\b/) - }) - - it('returns the posix script even on win32 when targeting a remote', () => { - stubPlatform('win32') - const script = getManagedStatusLineScript('posix') - expect(script).toContain('#!/bin/sh') - expect(script).not.toContain('curl.exe') - }) -}) - -describe('getManagedStatusLineScript (win32 local)', () => { - it('guards on rate_limits via findstr before the endpoint call and curl spawn', () => { - stubPlatform('win32') - const script = getManagedStatusLineScript('local') - const captureIndex = script.indexOf('more.com') - const guardIndex = script.indexOf('findstr.exe" /c:\\"rate_limits\\"') - const endpointIndex = script.indexOf('call "%ORCA_AGENT_HOOK_ENDPOINT%"') - const curlIndex = script.indexOf('curl.exe') - expect(captureIndex).toBeGreaterThan(-1) - expect(guardIndex).toBeGreaterThan(captureIndex) - expect(guardIndex).toBeLessThan(endpointIndex) - expect(endpointIndex).toBeLessThan(curlIndex) - expect(script).toContain('if errorlevel 1 goto :orca_statusline_cleanup') - }) - - it('posts the buffered payload file and deletes it afterwards', () => { - stubPlatform('win32') - const script = getManagedStatusLineScript('local') - expect(script).toContain('set "ORCA_STATUSLINE_PANE_ID=%ORCA_PANE_KEY:~-36%"') - expect(script).toContain('set "ORCA_STATUSLINE_PANE_ID=%ORCA_STATUSLINE_PANE_ID::=_%"') - expect(script).toContain( - 'set "ORCA_STATUSLINE_PAYLOAD_FILE=%TEMP%\\orca-claude-statusline-%ORCA_STATUSLINE_PANE_ID%.tmp"' - ) - expect(script).toContain('--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"') - expect(script).not.toContain('payload@-') - const curlIndex = script.indexOf('curl.exe') - const delIndex = script.indexOf('del "%ORCA_STATUSLINE_PAYLOAD_FILE%"') - expect(delIndex).toBeGreaterThan(curlIndex) - }) - - it('never posts a literal %CLAUDE_CONFIG_DIR% token when the var is unset', () => { - stubPlatform('win32') - const script = getManagedStatusLineScript('local') - expect(script).toContain('set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir="') - expect(script).toContain( - 'if defined CLAUDE_CONFIG_DIR set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir=%CLAUDE_CONFIG_DIR%"' - ) - expect(script).toContain('--data-urlencode "%ORCA_STATUSLINE_CONFIG_DIR_FIELD%"') - expect(script).not.toContain('"configDir=%CLAUDE_CONFIG_DIR%"') - }) - - it('exits without reading stdin when the pane key is missing (#24907)', () => { - stubPlatform('win32') - const script = getManagedStatusLineScript('local') - const paneGuardIndex = script.indexOf('if "%ORCA_PANE_KEY%"=="" exit /b 0') - expect(paneGuardIndex).toBeGreaterThan(-1) - expect(paneGuardIndex).toBeLessThan(script.indexOf('more.com')) - expect(script.match(/more\.com/g)).toHaveLength(1) - }) - - it('throttles with an all-builtin seconds-of-day stamp that fails open to posting', () => { - stubPlatform('win32') - const script = getManagedStatusLineScript('local') - const captureIndex = script.indexOf('more.com') - const stampIndex = script.indexOf( - 'set "ORCA_STATUSLINE_STAMP_FILE=%TEMP%\\orca-claude-statusline-last-%ORCA_STATUSLINE_PANE_ID%.tmp"' - ) - const throttleIndex = script.indexOf( - `if %ORCA_STATUSLINE_ELAPSED% GEQ 0 if %ORCA_STATUSLINE_ELAPSED% LSS ${CLAUDE_STATUSLINE_MIN_POST_INTERVAL_SECONDS} goto :orca_statusline_cleanup` - ) - const findstrIndex = script.indexOf('findstr.exe') - const stampWriteIndex = script.indexOf( - 'if defined ORCA_STATUSLINE_NOW (>"%ORCA_STATUSLINE_STAMP_FILE%" echo %ORCA_STATUSLINE_NOW%)' - ) - const tokenGuardIndex = script.indexOf('if "%ORCA_AGENT_HOOK_TOKEN%"=="" goto') - const curlIndex = script.indexOf('curl.exe') - expect(stampIndex).toBeGreaterThan(captureIndex) - expect(throttleIndex).toBeGreaterThan(stampIndex) - expect(throttleIndex).toBeLessThan(findstrIndex) - expect(stampWriteIndex).toBeGreaterThan(tokenGuardIndex) - expect(stampWriteIndex).toBeLessThan(curlIndex) - expect(script).toContain('if not defined ORCA_STATUSLINE_ELAPSED goto :orca_statusline_probe') - expect(script).toContain( - 'for /f "delims=0123456789" %%d in ("%ORCA_STATUSLINE_LAST%") do set "ORCA_STATUSLINE_LAST="' - ) - expect(script).toContain( - 'if defined ORCA_STATUSLINE_NOW if defined ORCA_STATUSLINE_LAST set /a "ORCA_STATUSLINE_ELAPSED=ORCA_STATUSLINE_NOW-ORCA_STATUSLINE_LAST" 2>nul' - ) - // Prefixing with 1 avoids cmd octal parsing of 08/09. - expect(script).toContain('(1%%a %% 100)*3600+(1%%b %% 100)*60+(1%%c %% 100)') - expect(script).toContain('set "ORCA_STATUSLINE_TIME=%TIME: =0%"') - }) -}) - describe.skipIf(process.platform === 'win32')('statusline curl throttle (posix behavioral)', () => { const LEAF_ID = '00000000-0000-4000-8000-000000000000' const PANE_KEY = 'tab-1:00000000-0000-4000-8000-000000000000' @@ -263,14 +149,6 @@ describe.skipIf(process.platform === 'win32')('statusline curl throttle (posix b expect(readFileSync(payloadLog, 'utf8')).toBe(payload) }) - it('fails open on a garbage stamp even early in a session', async () => { - const { scriptPath, dir, curlLog } = makeHarness() - writeFileSync(stampPathFor(dir), 'not-a-number') - await runScript(scriptPath, dir, rateLimitPayload(1_000)) - expect(lineCount(curlLog)).toBe(1) - expect(readFileSync(stampPathFor(dir), 'utf8')).toMatch(/^[0-9]+$/) - }) - it('fails open and repairs a leading-zero stamp instead of dying in arithmetic', async () => { const { scriptPath, dir, curlLog } = makeHarness() // 008 is invalid octal in dash arithmetic and must not prevent stamp repair. @@ -280,15 +158,6 @@ describe.skipIf(process.platform === 'win32')('statusline curl throttle (posix b expect(readFileSync(stampPathFor(dir), 'utf8')).toBe('1') }) - it('uses the clock fallback when the payload omits session duration', async () => { - const { scriptPath, dir, curlLog, dateLog } = makeHarness() - const payload = '{"rate_limits":{"five_hour":{"used_percentage":12}}}' - await runScript(scriptPath, dir, payload) - await runScript(scriptPath, dir, payload) - expect(lineCount(curlLog)).toBe(1) - expect(lineCount(dateLog)).toBe(2) - }) - it('uses only the stable leaf id for temp files', async () => { const { scriptPath, dir, curlLog } = makeHarness() const paneKey = `${'path/segment/'.repeat(30)}tab:${LEAF_ID}` diff --git a/src/main/claude/structured/claude-structured-child-work-captures.test.ts b/src/main/claude/structured/claude-structured-child-work-captures.test.ts deleted file mode 100644 index c450a454620..00000000000 --- a/src/main/claude/structured/claude-structured-child-work-captures.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { CapturedFrame } from '../claude-captured-frame-builders.test-fixture' -import { - INTERRUPTED_BETWEEN_TOOLS, - INTERRUPTED_IN_OWN_SHELL -} from '../claude-captured-foreground-frames.test-fixture' - -import { - MOVED_TO_BACKGROUND, - RESUMED_BY_MESSAGE -} from '../claude-captured-task-frames.test-fixture' -import { hostWithParent, producer, T0 } from '../claude-child-work-producer-harness.test-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) -afterEach(() => vi.restoreAllMocks()) - -function until(frames: CapturedFrame[], at: number): [CapturedFrame[], CapturedFrame[]] { - const split = frames.findIndex((captured) => captured.at >= at) - return [frames.slice(0, split), frames.slice(split)] -} - -describe('Claude child work from captured frame orders', () => { - it("keeps an agent's own shell live past the parent's turn, until the shell's own ending", async () => { - const run = await producer(hostWithParent()) - const [beforeEnding, fromEnding] = until(MOVED_TO_BACKGROUND, 51_275) - run.replay(beforeEnding) - // The parent turn ended at +12,610 ms; the child has no terminal frame yet. - expect(run.byDescription('Sleep 45 seconds then print 1')).toMatchObject({ - membership: 'live', - residency: 'foreground' - }) - run.replay(fromEnding) - const agent = run.byDescription('Run 45s sleep command') - expect(run.byDescription('Sleep 45 seconds then print 1')).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - settledAt: T0 + 51_275, - parentChildWorkId: agent?.childWorkId - }) - expect(agent).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - residency: 'background', - settledAt: T0 + 52_441, - lastMessage: 'The command completed after ~45 seconds. Exact stdout: `1`', - totalTokens: 22_160, - invocation: { invocationId: 'toolu_agent', generation: 1 } - }) - expect(run.ingested.flatMap((outcome) => outcome?.rejected ?? [])).toEqual([]) - }) - - it("does not end a moved agent on its spawn call's early result", async () => { - const run = await producer(hostWithParent()) - const launched = MOVED_TO_BACKGROUND.findIndex( - ({ frame }) => frame.type === 'user' && frame.parent_tool_use_id === null - ) - const moved = MOVED_TO_BACKGROUND.findIndex( - ({ frame }) => frame.subtype === 'background_tasks_changed' - ) - const reordered = [...MOVED_TO_BACKGROUND] - const [spawnResult] = reordered.splice(launched, 1) - reordered.splice(moved, 0, { ...spawnResult!, at: MOVED_TO_BACKGROUND[moved]!.at }) - run.replay(reordered) - const agent = run.byDescription('Run 45s sleep command') - expect(agent).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - settledAt: T0 + 52_441, - invocation: { invocationId: 'toolu_agent', generation: 1 } - }) - expect(agent?.previousInvocations).toBeUndefined() - }) - - it('keeps a background child live when a roster omits it without its own ending', async () => { - const run = await producer(hostWithParent()) - const [launch] = until(RESUMED_BY_MESSAGE, 5_077) - run.replay(launch) - run.replay([ - { at: 6_000, frame: { type: 'system', subtype: 'background_tasks_changed', tasks: [] } } - ]) - expect(run.byDescription('Run echo first-run command')).toMatchObject({ - membership: 'live', - residency: 'background' - }) - }) - - // Confirmed process exit supplies no child outcome without an accepted Stop. - it('leaves how they ended unknown when Orca ends the session, and keeps every record', async () => { - const run = await producer(hostWithParent()) - const [running] = until(MOVED_TO_BACKGROUND, 51_275) - run.replay(running) - await run.adapter.closeSession('session-1') - expect( - run.records().map(({ description, membership, outcome }) => ({ - description, - membership, - outcome - })) - ).toEqual([ - { description: 'Run 45s sleep command', membership: 'settled', outcome: 'unknown' }, - { description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'unknown' } - ]) - }) - - it('leaves how they ended unknown when the close sees a descendant survive', async () => { - const run = await producer(hostWithParent()) - const [running] = until(MOVED_TO_BACKGROUND, 51_275) - run.replay(running) - const connection = run.claude.connections[0]! - connection.exitVerdict = { root: 'exited', tree: 'live' } - connection.close = async () => false - await expect(run.adapter.closeSession('session-1')).rejects.toMatchObject({ - name: 'AgentSessionAcquisitionRootExitObservedError' - }) - expect(run.records().map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([ - { membership: 'settled', outcome: 'unknown' }, - { membership: 'settled', outcome: 'unknown' } - ]) - }) - - it('leaves how they ended unknown when the session dies on its own', async () => { - const run = await producer(hostWithParent()) - const [running] = until(MOVED_TO_BACKGROUND, 51_275) - run.replay(running) - run.claude.connections[0]!.handlers.onExit?.(new Error('claude exited')) - await run.adapter.drainObservedExits() - expect(run.records().map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([ - { membership: 'settled', outcome: 'unknown' }, - { membership: 'settled', outcome: 'unknown' } - ]) - }) - - it('opens a second run when a finished agent is started again', async () => { - const run = await producer(hostWithParent()) - const [firstRun, secondRun] = until(RESUMED_BY_MESSAGE, 16_118) - run.replay(firstRun) - expect(run.byDescription('Run echo first-run command')).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - invocation: { invocationId: 'toolu_agent', generation: 1 } - }) - const [restart, rest] = until(secondRun, 16_166) - run.replay(restart) - expect(run.byDescription('Run echo first-run command')).toMatchObject({ - membership: 'live', - invocation: { invocationId: 'toolu_message', generation: 2 }, - previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] - }) - run.replay(rest) - expect(run.byDescription('Run echo first-run command')).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - settledAt: T0 + 19_568, - lastMessage: 'The command executed successfully. Output: `second-run`', - totalTokens: 16_259, - invocation: { invocationId: 'toolu_message', generation: 2 } - }) - expect(run.records()).toHaveLength(1) - }) - - it('ends an agent interrupted between tools as cancelled, its own stop first', async () => { - const run = await producer(hostWithParent()) - run.replay(INTERRUPTED_BETWEEN_TOOLS) - expect(run.records()).toEqual([ - expect.objectContaining({ - description: 'Run sleep command and report', - membership: 'settled', - outcome: 'cancelled', - settledAt: T0 + 11_019 - }) - ]) - expect(run.ingested.flatMap((outcome) => outcome?.rejected ?? [])).toEqual([]) - }) - - it('ends an agent interrupted in its own shell, and the shell, as cancelled', async () => { - const run = await producer(hostWithParent()) - run.replay(INTERRUPTED_IN_OWN_SHELL) - const agent = run.byDescription('Run sleep command and report') - expect(agent).toMatchObject({ - membership: 'settled', - outcome: 'cancelled', - settledAt: T0 + 11_325 - }) - expect(run.byDescription('Sleep 45 seconds then print 1')).toMatchObject({ - membership: 'settled', - outcome: 'cancelled', - settledAt: T0 + 11_321, - parentChildWorkId: agent?.childWorkId - }) - expect(run.ingested.flatMap((outcome) => outcome?.rejected ?? [])).toEqual([]) - }) -}) diff --git a/src/main/claude/structured/claude-structured-child-work-producer.test.ts b/src/main/claude/structured/claude-structured-child-work-producer.test.ts deleted file mode 100644 index b8c7831d9a5..00000000000 --- a/src/main/claude/structured/claude-structured-child-work-producer.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - frame, - hostWithParent, - producer, - system, - toolResult, - toolUse -} from '../claude-child-work-producer-harness.test-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) -afterEach(() => vi.restoreAllMocks()) - -describe('Claude structured child-work producer', () => { - it('delivers evidence only after the journal wrote and published the frame', async () => { - const { send, records } = await producer() - const spawnCall = send(toolUse('toolu_bg', 'Agent', { description: 'Audit the build' })) - const announced = send( - system('task_started', { - task_id: 'agent-bg', - tool_use_id: 'toolu_bg', - task_type: 'local_agent', - subagent_type: 'general-purpose', - description: 'Audit the build', - is_backgrounded: true - }) - ) - const kinds = [...spawnCall, ...announced].map((delivery) => delivery.kind) - expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) - expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal')) - expect(kinds.at(-1)).toBe('evidence') - expect(kinds.filter((kind) => kind === 'evidence')).toHaveLength(1) - expect(records()).toEqual([ - expect.objectContaining({ description: 'Audit the build', membership: 'live' }) - ]) - }) - - it("settles each child only on its own ending, whatever the parent's turn or roster does", async () => { - const { adapter, send, records, byDescription } = await producer() - const steps: { message: Record; check?: () => void }[] = [ - { message: toolUse('toolu_fg', 'Agent', { description: 'Find flaky tests' }) }, - { - message: system('task_started', { - task_id: 'agent-fg', - tool_use_id: 'toolu_fg', - task_type: 'local_agent', - subagent_type: 'Explore', - description: 'Find flaky tests', - is_backgrounded: false - }) - }, - { - message: toolUse( - 'toolu_bash', - 'Bash', - { command: 'npm test', run_in_background: true }, - 'toolu_fg' - ), - check: () => - expect(byDescription('Find flaky tests')?.operation).toMatchObject({ - toolName: 'Bash', - input: 'npm test', - basis: 'open' - }) - }, - { - message: system('task_started', { - task_id: 'shell-1', - tool_use_id: 'toolu_bash', - task_type: 'local_bash', - description: 'npm test', - is_backgrounded: true - }), - check: () => - expect(byDescription('npm test')?.parentChildWorkId).toBe( - byDescription('Find flaky tests')?.childWorkId - ) - }, - { - message: system('background_tasks_changed', { - tasks: [{ task_id: 'shell-1', task_type: 'local_bash', description: 'npm test' }] - }) - }, - { - message: toolResult('toolu_bash', 'Command running in background', 'toolu_fg'), - check: () => expect(byDescription('Find flaky tests')?.operation).toBeUndefined() - }, - { - message: system('task_progress', { - task_id: 'agent-fg', - description: 'Running Bash', - last_tool_name: 'Bash', - usage: { total_tokens: 1_200, tool_uses: 2, duration_ms: 800 } - }), - check: () => - expect(byDescription('Find flaky tests')).toMatchObject({ - operation: { toolName: 'Bash', basis: 'reported' }, - totalTokens: 1_200 - }) - }, - { - message: toolResult('toolu_fg', 'Two tests flake on CI'), - check: () => expect(byDescription('Find flaky tests')?.membership).toBe('live') - }, - { - message: system('task_notification', { - task_id: 'agent-fg', - tool_use_id: 'toolu_fg', - status: 'completed', - summary: 'Two tests flake on CI' - }), - check: () => - expect(byDescription('Find flaky tests')).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - lastMessage: 'Two tests flake on CI' - }) - }, - { message: toolUse('toolu_bg', 'Agent', { description: 'Audit the build' }) }, - { - message: system('task_started', { - task_id: 'agent-bg', - tool_use_id: 'toolu_bg', - task_type: 'local_agent', - description: 'Audit the build', - is_backgrounded: true - }) - }, - { - message: system('background_tasks_changed', { - tasks: [ - { task_id: 'shell-1', task_type: 'local_bash', description: 'npm test' }, - { task_id: 'agent-bg', task_type: 'local_agent', description: 'Audit the build' } - ] - }) - }, - { message: frame({ type: 'result', subtype: 'success', is_error: false }) }, - // The provider can drop a roster entry before sending its terminal frame. - { - message: system('background_tasks_changed', { - tasks: [{ task_id: 'shell-1', task_type: 'local_bash', description: 'npm test' }] - }), - check: () => expect(byDescription('Audit the build')?.membership).toBe('live') - }, - { - message: system('task_notification', { - task_id: 'agent-bg', - status: 'failed', - summary: 'Build broke', - usage: { total_tokens: 900 } - }), - check: () => - expect(byDescription('Audit the build')).toMatchObject({ - membership: 'settled', - outcome: 'failed', - lastMessage: 'Build broke' - }) - }, - { - message: system('background_tasks_changed', { tasks: [] }), - check: () => expect(byDescription('npm test')?.membership).toBe('live') - }, - { - message: system('task_updated', { task_id: 'shell-1', patch: { status: 'killed' } }), - check: () => expect(byDescription('npm test')).toMatchObject({ outcome: 'cancelled' }) - }, - { message: toolUse('toolu_msg', 'SendMessage', { to: 'agent-bg', message: 'Again' }) }, - { - message: system('task_started', { - task_id: 'agent-bg', - tool_use_id: 'toolu_msg', - task_type: 'local_agent', - description: 'Audit the build', - is_backgrounded: true - }), - check: () => - expect(byDescription('Audit the build')).toMatchObject({ - membership: 'live', - invocation: { invocationId: 'toolu_msg', generation: 2 }, - previousInvocations: [expect.objectContaining({ outcome: 'failed' })] - }) - } - ] - for (const step of steps) { - send(step.message) - step.check?.() - } - await adapter.closeSession('session-1') - const summary = records().map((record) => ({ - description: record.description, - membership: record.membership, - outcome: record.outcome, - generation: record.invocation.generation - })) - expect(summary).toEqual([ - { - description: 'Find flaky tests', - membership: 'settled', - outcome: 'succeeded', - generation: 1 - }, - { description: 'npm test', membership: 'settled', outcome: 'cancelled', generation: 1 }, - { description: 'Audit the build', membership: 'settled', outcome: 'unknown', generation: 2 } - ]) - expect(adapter.backgroundTaskState('session-1')).toBeUndefined() - }) - - it("counts a child's runs the way the journal does, and ends each run on its own frame", async () => { - const { adapter, send, byDescription, stamps } = await producer() - const start = (toolUseId: string) => - system('task_started', { - task_id: 'agent-fg', - tool_use_id: toolUseId, - task_type: 'local_agent', - description: 'Find flaky tests', - is_backgrounded: false - }) - const childSays = (toolUseId: string, text: string) => - frame({ - type: 'assistant', - parent_tool_use_id: toolUseId, - message: { id: `msg-${text}`, role: 'assistant', content: [{ type: 'text', text }] } - }) - // Journal attempt 1 is implicit. - const runs = (toolUseId: string) => ({ - generation: byDescription('Find flaky tests')?.invocation.generation, - attempt: stamps.findLast((stamp) => stamp.providerParentRef === toolUseId)?.attempt ?? 1 - }) - send(toolUse('toolu_1', 'Agent', { description: 'Find flaky tests' })) - send(start('toolu_1')) - send(childSays('toolu_1', 'first run')) - expect(runs('toolu_1')).toEqual({ generation: 1, attempt: 1 }) - send(toolResult('toolu_1', 'Found it')) - send(system('task_notification', { task_id: 'agent-fg', status: 'completed' })) - - send(toolUse('toolu_2', 'Agent', { description: 'Find flaky tests' })) - const legacy = adapter.backgroundTaskState('session-1') - send(start('toolu_2')) - // Legacy rows require a roster update; child-work evidence uses the start frame. - expect(adapter.backgroundTaskState('session-1')).toEqual(legacy) - send(childSays('toolu_2', 'second run')) - expect(runs('toolu_2')).toEqual({ generation: 2, attempt: 2 }) - send( - system('task_progress', { - task_id: 'agent-fg', - last_tool_name: 'Grep', - usage: { total_tokens: 300, tool_uses: 1, duration_ms: 10 } - }) - ) - expect(byDescription('Find flaky tests')).toMatchObject({ - membership: 'live', - operation: { toolName: 'Grep', basis: 'reported' }, - totalTokens: 300 - }) - send(toolResult('toolu_2', 'Could not reproduce', null, true)) - expect(byDescription('Find flaky tests')?.membership).toBe('live') - send(system('task_notification', { task_id: 'agent-fg', status: 'failed' })) - expect(byDescription('Find flaky tests')).toMatchObject({ - membership: 'settled', - outcome: 'failed', - invocation: { invocationId: 'toolu_2', generation: 2 }, - previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] - }) - }) - - describe('a second ending for a settled child', () => { - async function settledForegroundChild() { - const host = hostWithParent() - const warn = vi.spyOn(console, 'warn') - const error = vi.spyOn(console, 'error') - const run = await producer(host) - run.send(toolUse('toolu_fg', 'Agent', { description: 'Find flaky tests' })) - run.send( - system('task_started', { - task_id: 'agent-fg', - tool_use_id: 'toolu_fg', - task_type: 'local_agent', - description: 'Find flaky tests', - is_backgrounded: false - }) - ) - run.send( - system('task_notification', { - task_id: 'agent-fg', - tool_use_id: 'toolu_fg', - status: 'completed', - summary: 'Two tests flake on CI' - }) - ) - expect(run.byDescription('Find flaky tests')).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - lastMessage: 'Two tests flake on CI' - }) - return { ...run, warn, error } - } - - it('keeps a definite outcome through an unclassified ending and lands its evidence', async () => { - const { send, byDescription } = await settledForegroundChild() - send( - system('task_notification', { - task_id: 'agent-fg', - summary: 'Two tests flake on CI; both time out on the shared runner', - usage: { total_tokens: 1_500 } - }) - ) - expect(byDescription('Find flaky tests')).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - lastMessage: 'Two tests flake on CI; both time out on the shared runner', - totalTokens: 1_500 - }) - }) - - it('keeps the first definite outcome over a conflicting one, and reports no fault', async () => { - const { send, byDescription, ingested, warn, error } = await settledForegroundChild() - send( - system('task_notification', { - task_id: 'agent-fg', - status: 'failed', - summary: 'Crashed after returning' - }) - ) - expect(byDescription('Find flaky tests')).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - lastMessage: 'Two tests flake on CI' - }) - expect(ingested.at(-1)).toMatchObject({ - settled: 0, - rejected: [{ handleId: 'agent-fg', reason: 'stale-invocation' }] - }) - expect(warn).not.toHaveBeenCalled() - expect(error).not.toHaveBeenCalled() - }) - }) -}) diff --git a/src/main/claude/structured/claude-structured-control-actions.test.ts b/src/main/claude/structured/claude-structured-control-actions.test.ts deleted file mode 100644 index 29c3fee7d82..00000000000 --- a/src/main/claude/structured/claude-structured-control-actions.test.ts +++ /dev/null @@ -1,459 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - cancelClaudeTurn, - answerClaudePrompt, - stopClaudeBackgroundTasks -} from './claude-structured-control-actions' -import { dispatchClaudeTurn } from './claude-structured-dispatch' -import { - ClaudeControlRequestError, - ClaudeControlRequestTimeoutError, - runClaudeControl -} from '../claude-agent-sdk-control-requests' -import { buildClaudePromptReply } from './journal/claude-structured-prompt-replies' -import { ClaudePromptRegistry } from '../claude-prompt-registry' -import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' -import { ClaudeChildWorkDecoder } from '../claude-child-work-decoder' -import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' - -type InterruptResult = Awaited> - -function sessionWith(input: { - capabilities?: string[] - interrupt: (options?: { cancelQueued?: boolean; timeoutMs?: number }) => Promise - cancelAsyncMessage?: (uuid: string) => Promise - prompts?: ClaudePromptRegistry -}): { - session: ClaudeSession - interrupt: ReturnType - cancelAsyncMessage: ReturnType -} { - const interrupt = vi.fn(input.interrupt) - const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => false)) - const session = sessionFor() - session.capabilities = input.capabilities ?? [] - session.prompts = input.prompts ?? new ClaudePromptRegistry() - session.connection.interrupt = interrupt - session.connection.cancelAsyncMessage = cancelAsyncMessage - return { session, interrupt, cancelAsyncMessage } -} - -describe('cancelClaudeTurn', () => { - it.each(['deadline', 'replacement'])( - 'bounds the legacy withdrawal sweep and retains confirmed sends: %s', - async (mode) => { - let current = true - const { session, cancelAsyncMessage } = sessionWith({ - capabilities: ['interrupt_receipt_v1'], - interrupt: async () => { - vi.setSystemTime(2_900) - return { still_queued: ['queued-1', 'queued-2', 'queued-3'] } - } - }) - cancelAsyncMessage.mockImplementation( - async (uuid: string, options: { timeoutMs: number }) => { - if (uuid === 'queued-1') { - current = mode !== 'replacement' - return true - } - return runClaudeControl( - 'cancel_async_message', - () => new Promise(() => {}), - options.timeoutMs - ) - } - ) - const settled = vi.fn() - session.connection.send = vi.fn(async () => { - throw new Error('lost write') - }) - await dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'queued' }]) - }) - session.retiredDispatchWaiters[0]!.sentUuid = 'queued-1' - vi.useFakeTimers() - vi.setSystemTime(0) - try { - const stopping = cancelClaudeTurn(session, 3_000, () => current, settled, 0) - await vi.advanceTimersByTimeAsync(100) - expect(await stopping).toEqual({ cancelled: true }) - expect(cancelAsyncMessage).toHaveBeenCalledTimes(mode === 'deadline' ? 2 : 1) - expect(settled).toHaveBeenCalledOnce() - expect(session.retiredDispatchWaiters).toEqual([]) - } finally { - vi.useRealTimers() - } - } - ) - - 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 }) - expect(interrupt).toHaveBeenCalledWith({ timeoutMs: 5_000 }) - expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2']) - }) - - it('settles each still-queued send the CLI confirms it withdrew, and only those', async () => { - const { session, cancelAsyncMessage } = sessionWith({ - capabilities: ['interrupt_receipt_v1'], - interrupt: async () => ({ still_queued: ['queued-1', 'queued-2', 'queued-3'] }), - // queued-2 already ran; queued-3's answer never arrived. - cancelAsyncMessage: async (uuid) => { - if (uuid === 'queued-3') { - throw new ClaudeControlRequestError('cancel_async_message', 'timed out') - } - return uuid === 'queued-1' - } - }) - const resolutions = [vi.fn(), vi.fn(), vi.fn()] - session.dispatchWaiters = ['queued-1', 'queued-2', 'queued-3'].map( - (sentUuid, index): ClaudeDispatchWaiter => ({ - acceptsResult: false, - clientMessageId: `client-${index + 1}`, - sentUuid, - dispatchSequence: index + 1, - requestedAt: null, - replayContentKey: `content-${index}`, - resolve: resolutions[index]! - }) - ) - const settled = vi.fn() - - await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ - cancelled: true - }) - expect(cancelAsyncMessage).toHaveBeenCalledTimes(3) - expect(settled.mock.calls).toEqual([ - [ - { - clientMessageId: 'client-1', - state: 'rejected', - unrun: true, - reason: 'provider_cancelled_before_start', - rejection: { kind: 'cancelled' } - } - ] - ]) - expect(resolutions[0]).toHaveBeenCalledWith(null) - expect(session.dispatchWaiters.map((waiter) => waiter.sentUuid)).toEqual([ - 'queued-2', - 'queued-3' - ]) - }) - - it('settles every cancelled queued waiter when the CLI advertises the capability', async () => { - const cancelled = Array.from({ length: 64 }, (_, index) => `queued-${index}`) - const { session, interrupt, cancelAsyncMessage } = sessionWith({ - capabilities: ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1'], - interrupt: async () => ({ still_queued: [], cancelled }) - }) - const resolutions = cancelled.map(() => vi.fn()) - session.dispatchWaiters = cancelled.map((sentUuid, index): ClaudeDispatchWaiter => ({ - acceptsResult: false, - clientMessageId: `client-${index}`, - sentUuid, - dispatchSequence: index + 1, - requestedAt: null, - replayContentKey: `content-${index}`, - resolve: resolutions[index]! - })) - const settled = vi.fn() - - await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ - cancelled: true - }) - expect(interrupt).toHaveBeenCalledWith({ cancelQueued: true, timeoutMs: 5_000 }) - expect(cancelAsyncMessage).not.toHaveBeenCalled() - expect(session.dispatchWaiters).toEqual([]) - expect(resolutions.every((resolve) => resolve.mock.calls[0]?.[0] === null)).toBe(true) - expect(settled).toHaveBeenCalledTimes(64) - expect(settled).toHaveBeenNthCalledWith(1, { - clientMessageId: 'client-0', - state: 'rejected', - unrun: true, - reason: 'provider_cancelled_before_start', - rejection: { kind: 'cancelled' } - }) - }) - - it('rejects an ambiguously written dispatch when a later interrupt confirms it was cancelled', async () => { - let cancelledUuid = '' - const { session } = sessionWith({ - capabilities: ['interrupt_cancel_queued_v1'], - interrupt: async () => ({ still_queued: [], cancelled: [cancelledUuid] }) - }) - session.connection.send = vi.fn(async () => { - throw new Error('connection lost after write') - }) - const settled = vi.fn() - - await expect( - dispatchClaudeTurn(session, { - clientMessageId: 'client-ambiguous', - body: userMessage([{ type: 'text', text: 'queued' }]) - }) - ).resolves.toMatchObject({ state: 'unknown' }) - expect(session.dispatchWaiters).toEqual([]) - expect(session.retiredDispatchWaiters).toHaveLength(1) - cancelledUuid = session.retiredDispatchWaiters[0]!.sentUuid - - await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ - cancelled: true - }) - expect(session.retiredDispatchWaiters).toEqual([]) - expect(settled).toHaveBeenCalledOnce() - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-ambiguous', - state: 'rejected', - unrun: true, - reason: 'provider_cancelled_before_start', - rejection: { kind: 'cancelled' } - }) - }) - - 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('forgets a pending prompt and its claim when teardown clears the registry', () => { - const prompts = new ClaudePromptRegistry() - const settle = vi.fn() - const prompt = prompts.register({ - requestId: 'perm-clear', - toolName: 'Bash', - toolUseId: 'tool-clear', - input: { command: 'ls' }, - suggestions: [], - settle - })! - prompts.bindJournalItemId('journal-clear', prompt.promptKey) - const claim = prompts.claim('journal-clear', 'approval') - if (!claim) { - throw new Error('expected prompt claim') - } - - expect(prompts.clear()).toEqual([prompt]) - expect(prompts.find('journal-clear')).toBeNull() - expect(prompts.ownsClaim(claim)).toBe(false) - expect(settle).not.toHaveBeenCalled() - }) - - 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 }) - const resolvePrompt = vi.fn() - session.translator = { - handle: vi.fn(), - openTurnInLiveProviderCycle: false, - openTurnHasInputWork: false, - journalPrompts: { - resolve: resolvePrompt, - handOver: () => () => {}, - cancel: () => ({ accepted: true }), - openCards: () => [][Symbol.iterator](), - whenWritten: () => undefined - }, - currentTurnId: null, - currentTurnScope: () => ({ kind: 'thread' }), - commandTurnId: null, - beginCommand: vi.fn(), - forgetCommand: vi.fn(), - commandInterruptRequested: vi.fn(), - flush: vi.fn(), - contextActivity: 0, - finalOutputAdmitted: true, - markContextActivity: vi.fn(), - subscribeContextUsageRequests: () => () => {}, - recordContextReport: () => {}, - modelMayHaveChanged: () => {}, - modelWritten: () => {}, - pendingStreamedBlocks: 0, - dispose: vi.fn() - } - - const claim = prompts.claim('journal-1', 'approval') - if (!claim) { - throw new Error('expected prompt claim') - } - await answerClaudePrompt( - session, - claim, - buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allow' }) - ) - - expect(settle).toHaveBeenCalledWith( - expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' }) - ) - expect(prompts.find('journal-1')).toBeNull() - expect(resolvePrompt).toHaveBeenCalledWith(prompt.promptKey) - }) - - it('refuses to claim a prompt Claude is no longer waiting on', () => { - const prompts = new ClaudePromptRegistry() - expect(prompts.claim('missing', 'approval')).toBeNull() - }) -}) - -describe('stopClaudeBackgroundTasks', () => { - function liveChildWork(ids: string[]): ClaudeChildWorkDecoder { - const childWork = new ClaudeChildWorkDecoder() - for (const id of ids) { - childWork.observe({ - type: 'system', - subtype: 'task_started', - task_id: id, - task_type: 'local_agent', - is_backgrounded: true - }) - } - childWork.drain(1) - return childWork - } - - function stoppingSession( - childWork: ClaudeChildWorkDecoder, - stopTask: (taskId: string, options?: { timeoutMs?: number }) => Promise - ): ClaudeSession { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background stop reads only the session's child-work decoder and its connection's stopTask. - return { childWork, connection: { stopTask } } as unknown as ClaudeSession - } - - it('stops exactly the ids the host named, and reports each acknowledged one', async () => { - const childWork = liveChildWork(['task-agent', 'task-bash']) - const stopTask = vi.fn(async (_taskId: string, _options?: { timeoutMs?: number }) => {}) - const session = stoppingSession(childWork, stopTask) - const acknowledge = vi.fn() - - await expect( - stopClaudeBackgroundTasks( - session, - 5_000, - () => true, - ['task-agent', 'task-bash'], - acknowledge - ) - ).resolves.toEqual({ cancelled: true }) - expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-agent' }], [{ taskId: 'task-bash' }]]) - expect(stopTask.mock.calls).toEqual([ - ['task-agent', { timeoutMs: 5_000 }], - ['task-bash', { timeoutMs: 5_000 }] - ]) - // Stop acknowledgements belong to the host, not the provider decoder. - expect(childWork.drain(2)).toEqual([]) - }) - - it('stops issuing requests when ownership changes between tasks', async () => { - const childWork = liveChildWork(['task-1', 'task-2']) - let current = true - const stopTask = vi.fn(async (_taskId: string) => { - current = false - }) - const session = stoppingSession(childWork, stopTask) - - await stopClaudeBackgroundTasks(session, undefined, () => current, ['task-1', 'task-2']) - expect(stopTask).toHaveBeenCalledTimes(1) - }) - - it('leaves a task live when the CLI refuses its stop, and makes no call for no ids', async () => { - const childWork = liveChildWork(['task-live']) - const stopTask = vi.fn(async (_taskId: string) => { - throw new ClaudeControlRequestError('stop_task', 'Task task-live is owned by another agent') - }) - const session = stoppingSession(childWork, stopTask) - const acknowledge = vi.fn() - - await expect( - stopClaudeBackgroundTasks(session, undefined, () => true, ['task-live'], acknowledge) - ).resolves.toEqual({ cancelled: false }) - await expect( - stopClaudeBackgroundTasks(session, undefined, () => true, [], acknowledge) - ).resolves.toEqual({ cancelled: false }) - expect(stopTask).toHaveBeenCalledTimes(1) - expect(acknowledge).not.toHaveBeenCalled() - }) - - it('stops the remaining tasks after one request fails, then reports the failure', async () => { - const childWork = liveChildWork(['task-1', 'task-2']) - const lost = new Error('connection reset') - const stopTask = vi.fn(async (taskId: string) => { - if (taskId === 'task-1') { - throw lost - } - }) - const session = stoppingSession(childWork, stopTask) - const acknowledge = vi.fn() - - await expect( - stopClaudeBackgroundTasks(session, undefined, () => true, ['task-1', 'task-2'], acknowledge) - ).rejects.toBe(lost) - expect(stopTask.mock.calls.map(([taskId]) => taskId)).toEqual(['task-1', 'task-2']) - expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-2' }]]) - }) - - it('stops asking after a request times out: a CLI not answering costs one deadline, not one per task', async () => { - const childWork = liveChildWork(['task-0', 'task-1', 'task-2', 'task-3', 'task-4']) - const stopTask = vi.fn((taskId: string, options?: { timeoutMs?: number }) => - taskId === 'task-0' - ? Promise.resolve() - : runClaudeControl('stop_task', () => new Promise(() => {}), options?.timeoutMs) - ) - const session = stoppingSession(childWork, stopTask) - const acknowledge = vi.fn() - - await expect( - stopClaudeBackgroundTasks( - session, - 50, - () => true, - ['task-0', 'task-1', 'task-2', 'task-3', 'task-4'], - acknowledge - ) - ).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError) - expect(stopTask).toHaveBeenCalledTimes(2) - expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-0' }]]) - expect(childWork.drain(2)).toEqual([]) - }) -}) diff --git a/src/main/claude/structured/claude-structured-dispatch.test.ts b/src/main/claude/structured/claude-structured-dispatch.test.ts deleted file mode 100644 index 3e3c8be65b4..00000000000 --- a/src/main/claude/structured/claude-structured-dispatch.test.ts +++ /dev/null @@ -1,777 +0,0 @@ -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 { dispatchClaudeTurn } from './claude-structured-dispatch' -import { resolveClaudeReplayTurn } from '../claude-replay-turn-resolution' -import { ClaudeDispatchContentError, readClaudeImage } from './claude-structured-dispatch-content' -import { claudeUnwrittenUserMessageError } from '../claude-agent-sdk-user-message-queue' -import type { ClaudeSession } from './claude-structured-session-state' -import { - childExited, - sessionFor, - userMessage, - userReplayFrame -} from './claude-structured-dispatch-test-support' - -function resolveClaudeReplayWaiter(...args: Parameters): boolean { - return resolveClaudeReplayTurn(...args) !== null -} - -describe('Claude structured dispatch image limits', () => { - it.each(['isMeta', 'isSynthetic', 'isCompactSummary'])( - 'does not acknowledge a dispatch with %s context even when the client uuid matches', - async (flag) => { - const session = sessionFor() - const settled = vi.fn() - const dispatched = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: '/example' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const sentUuid = session.dispatchWaiters[0]!.sentUuid - const replay = userReplayFrame(sentUuid, '/example') - expect(resolveClaudeReplayWaiter(session, { ...replay, [flag]: true }, settled)).toBe(false) - expect(session.dispatchWaiters).toHaveLength(1) - expect(settled).not.toHaveBeenCalled() - expect(resolveClaudeReplayWaiter(session, replay, settled)).toBe(true) - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-1', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: sentUuid } - }) - } - ) - - it('settles the waiter from a replay that lands after dispatch returned', async () => { - const session = sessionFor() - const dispatched = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'one' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - - expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(true) - expect(session.dispatchWaiters).toHaveLength(0) - }) - - it('settles a retired identity without reopening a turn after the child died', async () => { - const session = sessionFor() - const dispatched = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'one' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - childExited(session) - expect(session.dispatchWaiters).toHaveLength(0) - expect(session.retiredDispatchWaiters).toHaveLength(1) - - expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(false) - expect(session.retiredDispatchWaiters).toHaveLength(0) - }) - - it('settles the send the replay proves was delivered, whenever it arrives', async () => { - const session = sessionFor() - const settled = vi.fn() - const dispatched = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'one' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - - resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'), settled) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-1', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: sentUuid } - }) - }) - - it('settles a superseded dispatch even though it no longer owns the turn identity', async () => { - const session = sessionFor() - const settled = vi.fn() - const first = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'one' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - await expect(first).resolves.toEqual({ state: 'admitted' }) - childExited(session) - - const second = dispatchClaudeTurn(session, { - clientMessageId: 'client-2', - body: userMessage([{ type: 'text', text: 'two' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - - expect(resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'), settled)).toBe( - false - ) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-1', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: firstUuid } - }) - expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'), settled)).toBe( - true - ) - await expect(second).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenLastCalledWith({ - clientMessageId: 'client-2', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid } - }) - }) - - it('never lets a late replay for dispatch A resolve dispatch B', async () => { - const session = sessionFor() - const settled = vi.fn() - const first = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'one' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - await expect(first).resolves.toEqual({ state: 'admitted' }) - childExited(session) - - await expect( - dispatchClaudeTurn(session, { - clientMessageId: 'client-2', - body: userMessage([{ type: 'text', text: 'two' }]) - }) - ).resolves.toEqual({ state: 'admitted' }) - 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'), settled)).toBe( - true - ) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-2', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid } - }) - }) - - it('does not let an identical late replay for dispatch A resolve active dispatch B', async () => { - const session = sessionFor() - const settled = vi.fn() - const first = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'same prompt' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toEqual({ state: 'admitted' }) - childExited(session) - - const second = dispatchClaudeTurn(session, { - clientMessageId: 'client-2', - body: userMessage([{ type: 'text', text: 'same prompt' }]) - }) - 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'), settled) - await expect(second).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-2', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', 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' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toEqual({ state: 'admitted' }) - childExited(session) - 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' }]) - }) - ) - ) - expect(fillerDispatches.every((outcome) => outcome.state === 'admitted')).toBe(true) - childExited(session) - 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' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const secondUuid = session.dispatchWaiters[0]!.sentUuid - const settled = vi.fn() - - expect( - resolveClaudeReplayWaiter(session, userReplayFrame('provider-a-late', 'same prompt')) - ).toBe(false) - expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) - - resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'), settled) - await expect(second).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-2', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', 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' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toEqual({ state: 'admitted' }) - childExited(session) - 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' }]) - }) - ) - ) - expect(fillerDispatches.every((outcome) => outcome.state === 'admitted')).toBe(true) - childExited(session) - 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' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const secondUuid = session.dispatchWaiters[0]!.sentUuid - const settled = vi.fn() - - 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 - }, - settled - ) - ).toBe(false) - await expect(second).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-2', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', 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 settled = vi.fn() - const first = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'ordinary' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toEqual({ state: 'admitted' }) - childExited(session) - - const second = dispatchClaudeTurn(session, { - clientMessageId: 'client-2', - body: userMessage([{ type: 'text', text: '/permissions' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - - expect( - resolveClaudeReplayWaiter( - session, - { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'legacy-result-a' - }, - settled - ) - ).toBe(false) - await expect(second).resolves.toEqual({ state: 'admitted' }) - expect(session.dispatchWaiters).toHaveLength(1) - expect(settled).not.toHaveBeenCalled() - }) - - it('removes only its own waiter when a later send fails', async () => { - const session = sessionFor() - const settled = vi.fn() - const first = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'one' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - const firstWaiter = session.dispatchWaiters[0] - session.connection.send = vi - .fn() - .mockRejectedValue(claudeUnwrittenUserMessageError(new Error('broken pipe'))) - - await expect( - dispatchClaudeTurn(session, { - clientMessageId: 'client-2', - body: userMessage([{ type: 'text', text: 'two' }]) - }) - ).resolves.toEqual({ - state: 'rejected', - reason: 'provider_write_failed', - rejection: { kind: 'writeFailed' } - }) - expect(session.dispatchWaiters).toEqual([firstWaiter]) - - const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid - resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'), settled) - await expect(first).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-1', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: firstUuid } - }) - }) - - it('does not let a provably unwritten attempt block retry correlation', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const send = vi - .fn() - .mockRejectedValueOnce(claudeUnwrittenUserMessageError(new Error('broken pipe'))) - .mockResolvedValue(undefined) - const session = sessionFor(send) - const body = userMessage([{ type: 'text', text: 'retry me' }]) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ - state: 'rejected', - reason: 'provider_write_failed', - rejection: { kind: 'writeFailed' } - }) - expect(warn).toHaveBeenCalledWith( - '[claude-dispatch] message could not be handed to Claude:', - expect.objectContaining({ message: expect.stringContaining('broken pipe') }) - ) - warn.mockRestore() - expect(session.dispatchWaiters).toHaveLength(0) - expect(session.retiredDispatchWaiters).toHaveLength(0) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ state: 'admitted' }) - expect(resolveClaudeReplayWaiter(session, userReplayFrame('fresh-replay', 'retry me'))).toBe( - true - ) - expect(session.dispatchWaiters).toHaveLength(0) - }) - - it('does not claim an SDK-pulled frame was unwritten when its write outcome is ambiguous', async () => { - const session = sessionFor(vi.fn().mockRejectedValue(new Error('input pump stopped'))) - - await expect( - dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'one' }]) - }) - ).resolves.toEqual({ - state: 'unknown', - reason: 'provider_write_outcome_unknown: input pump stopped' - }) - }) - - it('keeps a replay accepted before its send reports failure', async () => { - let session!: ClaudeSession - const send = vi.fn(async (message: Record) => { - 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' }]) - }) - ).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 settled = vi.fn() - const dispatched = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: '/permissions' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - - expect( - resolveClaudeReplayWaiter( - session, - { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'command-result-uuid' - }, - settled - ) - ).toBe(false) - - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-1', - providerIdentity: { - provider: 'claude', - sessionId: 'provider-session', - uuid: 'command-result-uuid' - } - }) - }) - - it('accepts a slash command sent with an attachment from its result receipt', async () => { - const session = sessionFor() - const settled = vi.fn() - const dispatched = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([ - { type: 'text', text: '/permissions' }, - { type: 'image-ref', url: 'https://example.test/a.png' } - ]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - expect( - resolveClaudeReplayWaiter( - session, - { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'command-result-uuid' - }, - settled - ) - ).toBe(false) - - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-1', - providerIdentity: { - provider: 'claude', - sessionId: 'provider-session', - uuid: 'command-result-uuid' - } - }) - expect(session.connection.send).toHaveBeenCalledWith( - expect.objectContaining({ - message: { - role: 'user', - content: [ - { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, - { type: 'text', text: '/permissions' } - ] - } - }) - ) - }) - - it('does not take a result receipt for leading whitespace Claude never reads as a command', async () => { - const session = sessionFor() - const settled = vi.fn() - const dispatched = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: ' /permissions' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - - expect( - resolveClaudeReplayWaiter( - session, - { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'unrelated-result-uuid' - }, - settled - ) - ).toBe(false) - - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - expect(session.dispatchWaiters).toHaveLength(1) - expect(settled).not.toHaveBeenCalled() - }) - - it('correlates a later slash-command result by user_message_uuid despite a retired slash waiter', async () => { - const session = sessionFor() - const settled = vi.fn() - const first = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: '/permissions' }]) - }) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toEqual({ state: 'admitted' }) - childExited(session) - - const second = dispatchClaudeTurn(session, { - clientMessageId: 'client-2', - body: userMessage([{ type: 'text', text: '/permissions' }]) - }) - 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 - }, - settled - ) - ).toBe(false) - await expect(second).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-2', - providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: 'result-b' } - }) - }) - - it('does not mistake a normal turn result for its missing user replay', async () => { - const session = sessionFor() - const settled = vi.fn() - const dispatched = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: 'hello' }]) - }) - 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' }] - } - }, - settled - ) - ).toBe(true) - - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-1', - providerIdentity: { - provider: 'claude', - sessionId: 'provider-session', - 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 settled = vi.fn() - const dispatched = dispatchClaudeTurn(session, { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: '/permissions' }]) - }) - 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' }] - } - }, - settled - ) - - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - clientMessageId: 'client-1', - providerIdentity: { - provider: 'claude', - sessionId: 'provider-session', - uuid: 'user-replay-uuid' - } - }) - }) - - 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 }]) - }) - 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.toEqual({ state: 'admitted' }) - 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 are retired', 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 }) - ) - ) - childExited(session) - - 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) - const rejected = await readClaudeImage('/controlled/growing.png', open).catch( - (error: unknown) => error - ) - expect(rejected).not.toBeInstanceOf(ClaudeDispatchContentError) - expect(rejected).toMatchObject({ message: 'Claude image changed while it was read' }) - }) -}) diff --git a/src/main/claude/structured/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/structured/claude-structured-effort-default-at-rest.test.ts deleted file mode 100644 index 941e5a085b2..00000000000 --- a/src/main/claude/structured/claude-structured-effort-default-at-rest.test.ts +++ /dev/null @@ -1,512 +0,0 @@ -import { agentSessionRecordFixture } from '../../native-chat/agent-session-record-test-fixture' -import { claudeProviderHandle } from '../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' -import { agentSessionConversationView } from '../../../shared/agent-session/lifecycle/agent-session-conversation-view' -import { describe, expect, it } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-record' -import { getAgentSessionOptionCatalog } from '../../../shared/agent-session/options/agent-session-option-catalog' -import { - applyStructuredAgentSessionOptions, - createStructuredAgentSessionOptionState -} from '../../../shared/agent-session/options/structured-agent-session-options' -import { structuredAgentSessionOptionSnapshot } from '../../../shared/agent-session/options/structured-agent-session-option-snapshot' -import { createAgentModelCatalogService } from '../../native-chat/agent-model-catalog/agent-model-catalog-service' -import { - agentModelCatalogScopeFingerprint, - agentModelCatalogFingerprintForRecord -} from '../../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import { AgentModelCatalogStore } from '../../native-chat/agent-model-catalog/agent-model-catalog-store' -import type { StructuredAgentSessionMutationContext } from '../../native-chat/agent-session-wire/structured-agent-session-mutation-context' -import { readStructuredAgentSessionOptions } from '../../native-chat/agent-session-wire/structured-agent-session-options-read' -import { - structuredAgentSessionSendSelection, - structuredAgentSessionShownSnapshot -} from '../../../shared/agent-session/options/structured-agent-session-option-view' -import { claudeAndCodexAgents } from '../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' -import { composeCodexSessionOptionCatalog } from '../../codex/structured/catalog/codex-structured-model-catalog' -import { listedModels } from './launch/claude-structured-model-catalog' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - PROVIDER_SESSION_ID, - fakeClaude, - identityFor, - recordingJournalSink -} from './claude-structured-session-test-support' -import { - claudeStartupSettled, - claudeOpened -} from './claude-structured-startup-settled-test-support' - -const SESSION = 'session-1' -const ACCOUNT_HOME = '/accounts/claude' -const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] - -/** Claude Code 2.1.280's list_models rows: `default` resolves to the opus row. */ -const CATALOG = [ - { - value: 'default', - resolvedModel: 'claude-opus-5-5[1m]', - displayName: 'Default (recommended)', - supportsEffort: true, - supportedEffortLevels: EFFORTS - }, - { - value: 'opus[1m]', - resolvedModel: 'claude-opus-5-5[1m]', - displayName: 'Opus (1M context)', - supportsEffort: true, - supportedEffortLevels: EFFORTS - }, - { - value: 'sonnet', - resolvedModel: 'claude-sonnet-5', - displayName: 'Sonnet', - supportsEffort: true, - supportedEffortLevels: EFFORTS - } -] - -/** A settings write updates both effective and applied effort, as the CLI does. */ -function settingsWithNoOverride(model = 'claude-opus-5-5[1m]', listing: unknown[] = CATALOG) { - const effective: { effortLevel?: string } = {} - const settings = { - effective, - sources: [], - applied: { model, effort: 'medium', advisor: null, ultracode: false } - } - const claude = fakeClaude({ - initProof: 'session-start', - initModel: model, - initModels: listing, - settings, - routes: { - list_models: () => listing, - apply_flag_settings: (params) => { - const written = params?.settings - const effort = - typeof written === 'object' && written !== null && 'effortLevel' in written - ? written.effortLevel - : undefined - if (typeof effort === 'string') { - effective.effortLevel = effort - settings.applied.effort = effort - } - } - } - }) - return claude -} - -async function startChild( - store: AgentModelCatalogStore, - options?: Record, - events: ClaudeStructuredSessionEvent[] = [], - cli: { runs?: string; lists?: unknown[] } = {} -): Promise { - const adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: '/work/repo', - claudeConfigDir: ACCOUNT_HOME, - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - resumesTranscript: false, - cliFlag: async () => 'supported' as const, - continuesChain: false - }), - onEvent: (event) => events.push(event), - openConnection: settingsWithNoOverride(cli.runs, cli.lists).openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => 1_700_000_000_500, - persistHandle: async () => {} - }) - await adapter.acquire({ - identity: identityFor(SESSION), - fence: 7, - spawnToken: 'spawn-9', - events: recordingJournalSink(), - ...(options ? { options } : {}) - }) - await claudeStartupSettled(adapter, SESSION) - // Seed discovery independently of saved chat choices. - if (cli.lists?.length !== 0) { - const record = restingRecord({}) - store.recordSuccess( - exactKey(record), - 'claude', - { - models: listedModels({ models: CATALOG }), - origin: 'probe', - complete: true, - configured: { model: 'opus[1m]', effort: 'medium' } - }, - 'discovery' - ) - } - return adapter -} - -function restingRecord( - options: Record, - providerHandleChain: { origin: 'created' | 'adopted' }[] = [] -): AgentSessionRecord { - return agentSessionRecordFixture({ - sessionId: SESSION, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, - providerHandleChain: providerHandleChain.map(({ origin }, index) => ({ - origin, - linkId: `link-${index}`, - mintedAtFence: 1, - observedAt: 0, - handle: claudeProviderHandle('thread-1', null) - })), - location: { executionHostId: 'local', wslDistro: null }, - options - }) -} - -function exactKey(record: AgentSessionRecord): string { - return agentModelCatalogScopeFingerprint( - agentModelCatalogFingerprintForRecord(record), - '/work/repo' - ) -} - -function catalogDefault(store: AgentModelCatalogStore, model: string): string | undefined { - const entry = store.get(exactKey(restingRecord({}))) - return entry?.models.find((row) => row.id === model)?.defaultEffort -} - -function catalogService(store: AgentModelCatalogStore, record: AgentSessionRecord) { - return createAgentModelCatalogService({ - store, - getConversation: () => { - const saved = record - return saved ? agentSessionConversationView(saved) : undefined - }, - getRecord: () => record, - drivesRecord: () => true, - resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), - // No workspace overrides: use the account configuration. - recordWorkspacePath: async () => '/work/repo', - workspaceMayOverrideDefaultModel: async () => false - }) -} - -/** The options a client reads for the chat once its child is gone. */ -async function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { - const modelCatalog = catalogService(store, record) - // Acquiring this scope is separate from the passive picker read below. - await modelCatalog.prepareSaved({ - agent: record.provider, - sessionId: SESSION, - inSessionWorkspace: true - }) - const resting = { - child: null, - params: { provider: 'claude' }, - journal: { - threadGoal: () => null, - visitItems: () => {}, - inputOrder: { hasInputs: () => false }, - statusRead: () => ({ items: [], submissions: [] }), - contextUsage: () => null, - context: { floor: () => null } - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. - const context = { - deps: { - adapter: {}, - agents: claudeAndCodexAgents(), - store: { - getRecord: () => record, - getConversation: () => (record ? agentSessionConversationView(record) : null), - requireWritableRecord: () => record - }, - modelCatalog - }, - serialize: (_sessionId: string, task: () => Promise) => task(), - openConversation: async () => resting, - conversation: async () => resting - } as unknown as StructuredAgentSessionMutationContext - try { - return await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - } finally { - modelCatalog.stop() - } -} - -function pickerEffortRow(result: Awaited>, agent = 'claude') { - const seed = getAgentSessionOptionCatalog(agent)! - const state = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState(agent, seed), - seed, - result - ) - return structuredAgentSessionShownSnapshot(state).find((row) => row.id === 'effort') -} - -function pickerEffort(result: Awaited>, agent = 'claude') { - const effort = pickerEffortRow(result, agent) - return effort?.kind.type === 'select' ? effort.kind.currentValue : undefined -} - -const READ_ONLY_EFFORT = (value: string, label = value) => ({ - settable: false, - kind: { type: 'select', currentValue: value, choices: [{ value, label }] } -}) - -describe('Claude effort default at rest', () => { - it('uses the independently discovered configured effort at rest', async () => { - const store = new AgentModelCatalogStore() - await startChild(store) - - // Discovery measured Opus only; Sonnet has no observed default. - expect(catalogDefault(store, 'opus[1m]')).toBe('medium') - expect(catalogDefault(store, 'sonnet')).toBeUndefined() - }) - - it('shows that default in the picker of a chat at rest', async () => { - const store = new AgentModelCatalogStore() - await startChild(store) - - const result = await readAtRest(store, restingRecord({ model: 'opus[1m]' })) - - expect(result.current).toMatchObject({ model: 'opus[1m]' }) - expect(result.current.effort).toBeUndefined() - expect(pickerEffort(result)).toBe('medium') - }) - - it('names the default for a reopened chat with no saved model, and its run reports that row', async () => { - const store = new AgentModelCatalogStore() - const child = await startChild(store) - const seed = getAgentSessionOptionCatalog('claude')! - const picker = (answer: Awaited>) => { - const state = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState('claude', seed), - seed, - answer - ) - const row = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === 'model') - const shown = row?.kind.type === 'select' ? row.kind.currentValue : undefined - return { shown, sends: structuredAgentSessionSendSelection(state)?.model } - } - const reopened = await readAtRest(store, restingRecord({}, [{ origin: 'adopted' }])) - // The default row is keyed by the model it runs, so it is the row the run reports. - expect(reopened.current).toMatchObject({ model: 'claude-opus-5-5[1m]', defaulted: ['model'] }) - expect(picker(reopened)).toEqual({ shown: 'claude-opus-5-5[1m]', sends: undefined }) - - const ran = (await child.readOptions({ sessionId: SESSION, fence: 7 })).current - expect(ran).toMatchObject({ model: 'claude-opus-5-5[1m]' }) - expect(ran.defaulted ?? []).not.toContain('model') - }) - - it("shows the user's pick at rest over the default", async () => { - const store = new AgentModelCatalogStore() - await startChild(store) - - const result = await readAtRest(store, restingRecord({ model: 'opus[1m]', effort: 'max' })) - - expect(result.current.effort).toBe('max') - expect(pickerEffort(result)).toBe('max') - }) - - it("never learns a user's pick as the default", async () => { - const store = new AgentModelCatalogStore() - const restored = await startChild(store, { model: 'opus[1m]', effort: 'high' }) - await restored.readOptions({ sessionId: SESSION, fence: 7 }) - expect(catalogDefault(store, 'opus[1m]')).toBe('medium') - - const other = new AgentModelCatalogStore() - const live = await startChild(other) - await live.setOption({ sessionId: SESSION, fence: 7, key: 'effort', value: 'xhigh' }) - expect((await live.readOptions({ sessionId: SESSION, fence: 7 })).current.effort).toBe('xhigh') - expect(catalogDefault(other, 'opus[1m]')).toBe('medium') - }) - - it('offers no effort menu for a model the catalog does not list, at rest as live', async () => { - const store = new AgentModelCatalogStore() - const unlisted = { model: 'claude-unlisted-9', effort: 'medium' } - const child = await startChild(store, unlisted, [], { runs: unlisted.model }) - const live = await child.readOptions({ sessionId: SESSION, fence: 7 }) - - const resting = await readAtRest(store, restingRecord(unlisted)) - - const row = (result: typeof resting) => - result.models.find((entry) => entry.id === unlisted.model) - expect(row(live)).toEqual(expect.objectContaining({ id: unlisted.model, efforts: [] })) - expect(row(resting)).toEqual(row(live)) - // Supply the host catalog IDs when the live child omits them. - expect(pickerEffortRow({ ...live, listedModelIds: resting.listedModelIds })).toMatchObject( - READ_ONLY_EFFORT(unlisted.effort, 'Medium') - ) - expect(pickerEffortRow(resting)).toMatchObject(READ_ONLY_EFFORT(unlisted.effort, 'Medium')) - }) - - it('lists an unlisted Codex model at rest exactly as its live child does', async () => { - const store = new AgentModelCatalogStore() - const record = agentSessionRecordFixture({ - sessionId: SESSION, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - providerHandleChain: [], - location: { executionHostId: 'local', wslDistro: null }, - options: { model: 'gpt-unlisted', effort: 'high' } - }) - const listing = { - models: [ - { - id: 'gpt-5.5', - label: 'GPT-5.5', - isDefault: true, - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] - } - ] - } - store.recordSuccess( - exactKey(record), - 'codex', - { - ...listing, - origin: 'probe', - complete: true - }, - 'discovery' - ) - - const resting = await readAtRest(store, record) - const live = composeCodexSessionOptionCatalog(listing, { - current: { model: 'gpt-unlisted', effort: 'high' } - }) - - expect(resting.models).toEqual(live.models) - expect(live.models.find((entry) => entry.id === 'gpt-unlisted')).toMatchObject({ efforts: [] }) - for (const answer of [resting, { ...live, listedModelIds: resting.listedModelIds }]) { - expect(pickerEffortRow(answer, 'codex')).toMatchObject(READ_ONLY_EFFORT('high', 'High')) - } - }) - - it("keeps a Claude model's effort at rest with no catalog yet, as a live child with none does", async () => { - const saved = { model: 'opus', effort: 'high' } - const child = await startChild(new AgentModelCatalogStore(), saved, [], { - runs: 'opus', - lists: [] - }) - const live = await child.readOptions({ sessionId: SESSION, fence: 7 }) - - const resting = await readAtRest(new AgentModelCatalogStore(), restingRecord(saved)) - - expect(resting.models).toEqual(live.models) - expect(pickerEffort(live)).toBe('high') - expect(pickerEffort(resting)).toBe('high') - }) - - it('names no model at rest with no pick and no catalog, so the client keeps what the child ran', async () => { - const child = await startChild(new AgentModelCatalogStore(), undefined, [], { - runs: 'opus', - lists: [] - }) - const live = await child.readOptions({ sessionId: SESSION, fence: 7 }) - - const resting = await readAtRest(new AgentModelCatalogStore(), restingRecord({})) - - // Built-in defaults do not establish this account's configured model. - expect(resting.current).not.toHaveProperty('model') - const seed = getAgentSessionOptionCatalog('claude')! - const state = [live, resting].reduce( - (current, answer) => applyStructuredAgentSessionOptions(current, seed, answer), - createStructuredAgentSessionOptionState('claude', seed) - ) - const model = structuredAgentSessionOptionSnapshot(state).find((row) => row.id === 'model') - expect(model?.kind.type === 'select' ? model.kind.currentValue : undefined).toBe('opus') - }) - - it("keeps a Codex model's effort at rest with no catalog yet, from the client's own defaults", async () => { - const record = agentSessionRecordFixture({ - sessionId: SESSION, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - providerHandleChain: [], - location: { executionHostId: 'local', wslDistro: null }, - options: { model: 'gpt-5.5', effort: 'high' } - }) - - const resting = await readAtRest(new AgentModelCatalogStore(), record) - - expect(resting.models).toEqual([ - { - id: 'gpt-5.5', - label: 'GPT 5.5', - description: 'gpt-5.5', - isDefault: false, - efforts: [], - effortsKnown: false - } - ]) - expect(pickerEffort(resting, 'codex')).toBe('high') - }) - - it("leaves a Codex chat's unsaved effort blank at rest, as its live child does", async () => { - const store = new AgentModelCatalogStore() - const record = agentSessionRecordFixture({ - sessionId: SESSION, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - providerHandleChain: [], - location: { executionHostId: 'local', wslDistro: null }, - options: { model: 'gpt-5.5' } - }) - store.recordSuccess( - exactKey(record), - 'codex', - { - models: [ - { - id: 'gpt-5.5', - label: 'GPT-5.5', - isDefault: true, - defaultEffort: 'medium', - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] - } - ], - origin: 'probe', - complete: true - }, - 'discovery' - ) - - const result = await readAtRest(store, record) - - expect(result.current).toEqual({ - model: 'gpt-5.5', - defaulted: [], - permissionMode: 'ask', - acceptedInputSources: { model: 'saved' } - }) - expect(result.current?.effort).toBeUndefined() - }) - - it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => { - const store = new AgentModelCatalogStore() - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await startChild(store, undefined, events) - - const reported = (await claudeOpened(adapter, SESSION)).reportedOptions - expect(reported).not.toHaveProperty('effort') - const record = restingRecord({ model: 'opus[1m]' }) - expect(record.options).toEqual({ model: 'opus[1m]' }) - const result = await readAtRest(store, record) - expect(result.current.effort).toBeUndefined() - expect(pickerEffort(result)).toBe('medium') - expect(record.options).toEqual({ model: 'opus[1m]' }) - }) -}) diff --git a/src/main/claude/structured/claude-structured-execution-recording.test.ts b/src/main/claude/structured/claude-structured-execution-recording.test.ts index 33346e2d74f..1370e806856 100644 --- a/src/main/claude/structured/claude-structured-execution-recording.test.ts +++ b/src/main/claude/structured/claude-structured-execution-recording.test.ts @@ -92,55 +92,6 @@ it.each(['low', 'high', undefined])( } ) -it('retires earlier Fast routing when an effort read reports Fast off', async () => { - let fastMode = true - const models = [ - { - value: 'sonnet', - supportsEffort: true, - supportedEffortLevels: ['low', 'high'], - supportsFastMode: true - } - ] - const claude = fakeClaude({ - initModels: models, - routes: { list_models: () => models }, - settings: { - applied: { model: 'sonnet', effort: 'low' }, - get effective() { - return { effortLevel: 'high', fastMode } - } - } - }) - const adapter = await acquired(claude) - const send = (effort: string) => - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: effort, - body: USER_MESSAGE, - executionOptions: { model: 'sonnet', effort, fastMode: 'true' }, - fence: 7 - }) - try { - await send('high') - claudeTurnEnds(claude.connections[0]!) - claudeFrame(claude.connections[0]!, { - type: 'system', - subtype: 'status', - fast_mode_state: 'on' - }) - fastMode = false - expect(await send('low')).toMatchObject({ state: 'admitted', ranOptions: { effort: 'low' } }) - claudeTurnEnds(claude.connections[0]!) - expect(await send('low')).toEqual({ - state: 'admitted', - ranOptions: { model: 'sonnet', effort: 'low' } - }) - } finally { - await adapter.closeAll() - } -}) - it('never pairs an earlier unknown-model low run with the next prepared model', async () => { const applied: { model?: string; effort: string } = { effort: 'low' } const claude = fakeClaude({ initModel: '', settings: { applied } }) diff --git a/src/main/claude/structured/claude-structured-inbound-control.test.ts b/src/main/claude/structured/claude-structured-inbound-control.test.ts index 9a0d6f07568..d218b479698 100644 --- a/src/main/claude/structured/claude-structured-inbound-control.test.ts +++ b/src/main/claude/structured/claude-structured-inbound-control.test.ts @@ -1,19 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk' import { ClaudePromptRegistry } from '../claude-prompt-registry' -import { invokeCanUseTool } from '../claude-can-use-tool-test-support' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { claudeStructuredPermissionOptions } from './launch/claude-structured-launch-resolution' -import { - adapterFor, - fakeClaude, - identityFor, - PROVIDER_SESSION_ID, - recordingJournalSink, - tick, - type FakeConnection -} from './claude-structured-session-test-support' type CanUseToolOptions = Parameters[2] @@ -46,106 +34,6 @@ function callbacksFor(fullAccess = false) { } 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 controller = new AbortController() - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - const answered = control.canUseTool( - 'Bash', - { command: 'git status' }, - permissionOptions('perm-1', 'tool-1', controller.signal, [ - { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } - ]) - ) - - 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', rules: [], behavior: 'allow', destination: 'session' } - ]) - found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' }) - await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' }) - expect(removeListener).toHaveBeenCalledTimes(1) - }) - - it('keeps the SDK permission presentation and strips terminal escapes', async () => { - const control = callbacksFor() - const answered = control.canUseTool( - 'Read', - { file_path: '/repo/secrets.txt' }, - permissionOptions( - 'perm-presentation', - 'tool-presentation', - new AbortController().signal, - [], - { - title: '\u001b[31mClaude wants to read secrets.txt\u001b[0m', - displayName: 'Read file', - description: 'Read access outside the workspace', - decisionReason: '\u001b[33mThe path is outside the allowed root.\u001b[0m', - blockedPath: '/repo/secrets.txt', - matchedAskRule: { source: 'project', toolName: 'Read', ruleContent: '/repo/**' } - } - ) - ) - - const prompt = control.prompts.find('perm-presentation')?.prompt - expect(prompt).toMatchObject({ - title: 'Claude wants to read secrets.txt', - displayName: 'Read file', - description: 'Read access outside the workspace', - decisionReason: 'The path is outside the allowed root.', - blockedPath: '/repo/secrets.txt', - matchedAskRule: { source: 'project', toolName: 'Read', ruleContent: '/repo/**' } - }) - expect(JSON.stringify(prompt)).not.toContain('\\u001b') - prompt?.settle({ behavior: 'deny', message: 'done', toolUseID: 'tool-presentation' }) - await expect(answered).resolves.toMatchObject({ behavior: 'deny' }) - }) - - it('classifies a plan before generic permission registration', async () => { - const control = callbacksFor() - const register = vi.spyOn(control.prompts, 'register') - const answered = control.canUseTool( - 'ExitPlanMode', - { plan: '# Release\n\n- Run tests', planFilePath: '/repo/plan.md' }, - permissionOptions('perm-plan', 'tool-plan', new AbortController().signal) - ) - - expect(register).toHaveBeenCalledWith( - expect.objectContaining({ - subject: { kind: 'plan', text: '# Release\n\n- Run tests', filePath: '/repo/plan.md' } - }) - ) - const prompt = control.prompts.find('perm-plan')?.prompt - expect(prompt?.subject?.kind).toBe('plan') - prompt?.settle({ behavior: 'deny', message: 'done', toolUseID: 'tool-plan' }) - await expect(answered).resolves.toMatchObject({ behavior: 'deny' }) - }) - - 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() @@ -183,182 +71,4 @@ describe('Claude permission callbacks', () => { expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-4' }) ) }) - - it('still asks for questions, plan approval and interaction-flagged tools in Full access', () => { - const control = callbacksFor(true) - void control.canUseTool( - 'AskUserQuestion', - { questions: [{ question: 'Which?', header: 'Pick', options: [], multiSelect: false }] }, - permissionOptions('q', 'tool-q', new AbortController().signal) - ) - void control.canUseTool( - 'ExitPlanMode', - { plan: '# Plan' }, - permissionOptions('p', 'tool-p', new AbortController().signal) - ) - // The SDK passes the CLI's requires_user_interaction through, outside its declared type. - const flagged = { title: 'Confirm your answer', requiresUserInteraction: true } - void control.canUseTool( - 'mcp__forms__confirm', - { request: 'Please enter your answer' }, - permissionOptions('m', 'tool-m', new AbortController().signal, [], flagged) - ) - expect(control.prompts.find('q')).not.toBeNull() - expect(control.prompts.find('p')).not.toBeNull() - expect(control.prompts.find('m')).not.toBeNull() - }) -}) - -// The recorded Claude Code 2.1.310 safety-check ask under --dangerously-skip-permissions. -const SAFETY_CHECK = - 'Dangerous rm operation not ruled out — the shell may read this command differently than it was parsed' -const SHELL_INPUT = { - command: 'for v in HOME PATH; do echo "${(P)v}" | head -c 5; done', - description: 'Print the start of HOME and PATH' -} - -type WireItem = Record - -const modeFrame = (subtype: 'init' | 'status', permissionMode?: unknown): WireItem => ({ - type: 'system', - subtype, - ...(subtype === 'status' ? { status: null } : {}), - ...(permissionMode === undefined ? {} : { permissionMode }), - session_id: PROVIDER_SESSION_ID -}) -const ask = (id: string): WireItem => ({ - type: 'control_request', - request_id: id, - request: { - subtype: 'can_use_tool', - tool_name: 'Bash', - input: SHELL_INPUT, - tool_use_id: `tool-${id}`, - decision_reason: SAFETY_CHECK - } -}) -const allowed = (id: string) => ({ - behavior: 'allow', - updatedInput: SHELL_INPUT, - toolUseID: `tool-${id}` -}) - -function askFor(connection: FakeConnection, id: string) { - return invokeCanUseTool(connection, 'Bash', id, `tool-${id}`, { - input: SHELL_INPUT, - decisionReason: SAFETY_CHECK - }) -} - -async function outcome(request: ReturnType): Promise { - await tick() - return request.settled() ? request.promise : 'card' -} - -/** One stdout chunk, then each request in it, as the SDK asks only after reading the whole chunk. */ -function writeWire(connection: FakeConnection, wire: WireItem[]) { - connection.handlers.onStdout?.(wire.map((frame) => `${JSON.stringify(frame)}\n`).join('')) - return wire.flatMap((frame) => - frame.type === 'control_request' ? [askFor(connection, String(frame.request_id))] : [] - ) -} - -function overWire(connection: FakeConnection, wire: WireItem[]) { - return Promise.all(writeWire(connection, wire).map(outcome)) -} - -function cards(events: ClaudeStructuredSessionEvent[]): string[] { - return events.flatMap((event) => (event.type === 'prompt' ? [event.prompt.promptKey] : [])) -} - -async function acquireWith( - choice: Parameters[0], - events: ClaudeStructuredSessionEvent[], - beforePublish?: (connection: FakeConnection) => void -): Promise { - const claude = fakeClaude() - const adapter = adapterFor( - { - ...claude, - openConnection: async (launch, handlers) => { - const connection = await claude.openConnection(launch, handlers) - beforePublish?.(claude.connections.at(-1)!) - return connection - } - }, - { options: claudeStructuredPermissionOptions(choice) }, - events - ) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: recordingJournalSink() - }) - return claude.connections.at(-1)! -} - -describe('Full access through a Claude process', () => { - it("approves Claude's own asks only while the mode before each request is Full access", async () => { - const events: ClaudeStructuredSessionEvent[] = [] - let early: ReturnType | undefined - const connection = await acquireWith('full-access', events, (opened) => { - early = writeWire(opened, [ask('before-publish')])[0] - }) - await expect(outcome(early!)).resolves.toEqual(allowed('before-publish')) - // A request Orca never saw on stdout has no position to inherit Full access from. - await expect(outcome(askFor(connection, 'unseen'))).resolves.toBe('card') - - const table: [string, WireItem[], unknown[]][] = [ - ['before any report', [ask('first')], [allowed('first')]], - [ - 'init bypassPermissions', - [modeFrame('init', 'bypassPermissions'), ask('init')], - [allowed('init')] - ], - ['status default', [modeFrame('status', 'default'), ask('default')], ['card']], - ['a status without a mode', [modeFrame('status'), ask('absent')], ['card']], - [ - 'a later bypassPermissions', - [modeFrame('status', 'bypassPermissions'), ask('back')], - [allowed('back')] - ], - ...['plan', null, 'dontAsk', 'someFutureMode'].map( - (mode): [string, WireItem[], unknown[]] => [ - `${mode} then bypassPermissions in one chunk`, - [ - modeFrame('status', mode), - ask(`in-${mode}`), - modeFrame('status', 'bypassPermissions'), - ask(`after-${mode}`) - ], - ['card', allowed(`after-${mode}`)] - ] - ) - ] - for (const [label, wire, expected] of table) { - expect(await overWire(connection, wire), label).toEqual(expected) - } - expect(cards(events)).toEqual([ - 'unseen', - 'default', - 'absent', - 'in-plan', - 'in-null', - 'in-dontAsk', - 'in-someFutureMode' - ]) - }) - - it.each(['own', 'ask'] as const)( - 'keeps the card in a %s chat even when Claude reports bypassPermissions', - async (choice) => { - const events: ClaudeStructuredSessionEvent[] = [] - const connection = await acquireWith(choice, events) - expect( - await overWire(connection, [modeFrame('init', 'bypassPermissions'), ask('r')]) - ).toEqual(['card']) - expect(cards(events)).toEqual(['r']) - } - ) }) diff --git a/src/main/claude/structured/claude-structured-queued-stop.test.ts b/src/main/claude/structured/claude-structured-queued-stop.test.ts deleted file mode 100644 index b6f92aa720a..00000000000 --- a/src/main/claude/structured/claude-structured-queued-stop.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalItemBody } from '../../../shared/agent-session/journal/agent-session-journal-types' -import { agentJournalItemKey } from '../../../shared/agent-session/journal/agent-session-journal-item-key' -import { readAgentJournalTurn } from '../../../shared/agent-session/lifecycle/agent-session-turn-record' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/agent-session/requests/structured-agent-session-dispatch-rejection' -import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' -import { - PROVIDER_SESSION_ID, - USER_MESSAGE, - adapterFor, - fakeClaude, - identityFor, - type FakeConnection -} from './claude-structured-session-test-support' - -// Captured from Claude Code 2.1.280 system/init. -const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] - -type Settlement = Parameters< - NonNullable ->[0] - -function claudeCli(options: Parameters[0] = {}) { - let queued: string[] = [] - const claude = fakeClaude({ - replayUuid: null, - ...options, - routes: { - interrupt: (params) => - params?.cancelQueued - ? { still_queued: [], cancelled: queued.splice(0) } - : { still_queued: [...queued] }, - cancel_async_message: (params) => { - const before = queued.length - queued = queued.filter((uuid) => uuid !== params?.uuid) - return queued.length < before - } - } - }) - return { claude, queue: (uuid: string) => queued.push(uuid) } -} - -function turnInit(capabilities?: string[]): Record { - return { - type: 'system', - subtype: 'init', - session_id: PROVIDER_SESSION_ID, - uuid: 'turn-init', - model: 'claude-sonnet-5', - ...(capabilities ? { capabilities } : {}) - } -} - -async function acquired( - claude: ReturnType, - settlements: Settlement[] = [] -): Promise<{ - adapter: ReturnType - bodies: Map - connection: FakeConnection -}> { - const bodies = new Map() - const adapter = adapterFor(claude, {}, [], [], undefined, undefined, undefined, (settlement) => - settlements.push(settlement) - ) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: { - appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), - appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), - publish: vi.fn() - } - }) - return { adapter, bodies, connection: claude.connections[0]! } -} - -function openTurnId(bodies: Map): string | null { - for (const body of bodies.values()) { - const turn = readAgentJournalTurn(body) - if (turn?.state === 'running') { - return turn.turnId - } - } - return null -} - -function runningTurnId(bodies: Map): string { - const turnId = openTurnId(bodies) - if (turnId === null) { - throw new Error('expected a running turn') - } - return turnId -} - -function completeTurn(connection: FakeConnection, uuid: string): void { - connection.handlers.onMessage?.({ - type: 'result', - subtype: 'success', - uuid, - session_id: PROVIDER_SESSION_ID, - is_error: false, - terminal_reason: 'completed', - duration_ms: 12 - }) -} - -async function sendAndOpen( - adapter: ReturnType, - connection: FakeConnection, - clientMessageId: string, - openTurn: (connection: FakeConnection) => void = () => {} -): Promise { - await send(adapter, clientMessageId) - openTurn(connection) - connection.handlers.onMessage?.({ - ...connection.sent.at(-1)!, - uuid: connection.sent.at(-1)!.uuid - }) -} - -function send(adapter: ReturnType, clientMessageId: string) { - return adapter.dispatch({ sessionId: 'session-1', clientMessageId, body: USER_MESSAGE, fence: 7 }) -} - -/** Pending delivery must not delay Stop; read the outcome without advancing time. */ -async function namedStop(adapter: ReturnType, turnId: string): Promise { - vi.useFakeTimers() - try { - let outcome: unknown = 'still waiting' - void adapter - .cancelTurn({ - sessionId: 'session-1', - turnId, - fence: 7, - dispatchStatus: { state: 'pending', recovered: false } - }) - .then((result) => (outcome = result)) - await vi.advanceTimersByTimeAsync(0) - return outcome - } finally { - vi.useRealTimers() - } -} - -async function stopWithQueuedFollowUp( - cli: ReturnType, - openTurn: (connection: FakeConnection) => void -): Promise<{ stopped: unknown; interrupt: unknown; settlements: Settlement[] }> { - const settlements: Settlement[] = [] - const { adapter, bodies, connection } = await acquired(cli.claude, settlements) - await sendAndOpen(adapter, connection, 'client-a', openTurn) - await send(adapter, 'client-b') - cli.queue(String(connection.sent.at(-1)!.uuid)) - - const stopped = await namedStop(adapter, runningTurnId(bodies)) - return { - stopped, - interrupt: connection.calls.find((call) => call.subtype === 'interrupt')?.params, - settlements: settlements.filter((settlement) => settlement.clientMessageId === 'client-b') - } -} - -const WITHDRAWN = [ - { - sessionId: 'session-1', - clientMessageId: 'client-b', - state: 'rejected', - unrun: true, - reason: DISPATCH_REJECTED_CANCELLED, - rejection: { kind: 'cancelled' } - } -] - -describe('Stop with a follow-up Claude queued behind the running turn', () => { - it('withdraws it the same way whether system/init or a SessionStart hook proved the start', async () => { - const byInit = await stopWithQueuedFollowUp( - claudeCli({ initProof: 'init', capabilities: CAPABILITIES }), - () => {} - ) - const bySessionStart = await stopWithQueuedFollowUp( - claudeCli({ initProof: 'session-start' }), - (connection) => connection.handlers.onMessage?.(turnInit(CAPABILITIES)) - ) - - expect(byInit).toEqual({ - stopped: { cancelled: true }, - interrupt: { cancelQueued: true }, - settlements: WITHDRAWN - }) - expect(bySessionStart).toEqual(byInit) - }) - - it('keeps what a turn advertised when a later frame names nothing', async () => { - const result = await stopWithQueuedFollowUp( - claudeCli({ initProof: 'session-start' }), - (connection) => { - connection.handlers.onMessage?.(turnInit(CAPABILITIES)) - connection.handlers.onMessage?.(turnInit()) - connection.handlers.onMessage?.({ - type: 'system', - subtype: 'hook_response', - hook_name: 'SessionStart:resume', - session_id: PROVIDER_SESSION_ID - }) - } - ) - - expect(result).toEqual({ - stopped: { cancelled: true }, - interrupt: { cancelQueued: true }, - settlements: WITHDRAWN - }) - }) - - it('keeps what a turn advertised when the start is read after it', async () => { - const cli = claudeCli({ initProof: 'session-start' }) - const open = cli.claude.openConnection - // Deliver turn init before startup's get_settings resolves. - cli.claude.openConnection = async (launch, handlers) => { - const connection = await open(launch, handlers) - const getSettings = connection.getSettings - connection.getSettings = async (options) => { - handlers?.onMessage?.(turnInit(CAPABILITIES)) - return getSettings(options) - } - return connection - } - - const result = await stopWithQueuedFollowUp(cli, () => {}) - - expect(result).toEqual({ - stopped: { cancelled: true }, - interrupt: { cancelQueued: true }, - settlements: WITHDRAWN - }) - }) - - it('settles a follow-up withdrawn one at a time on a CLI without cancel_queued', async () => { - const result = await stopWithQueuedFollowUp( - claudeCli({ initProof: 'init', capabilities: ['interrupt_receipt_v1'] }), - () => {} - ) - - expect(result).toEqual({ stopped: { cancelled: true }, interrupt: {}, settlements: WITHDRAWN }) - }) - - it('still refuses a Stop that names an older turn than the one running', async () => { - const settlements: Settlement[] = [] - const cli = claudeCli({ initProof: 'init', capabilities: CAPABILITIES }) - const { adapter, bodies, connection } = await acquired(cli.claude, settlements) - await sendAndOpen(adapter, connection, 'client-a') - const olderTurnId = runningTurnId(bodies) - completeTurn(connection, 'result-a') - await sendAndOpen(adapter, connection, 'client-b') - const liveTurnId = runningTurnId(bodies) - await send(adapter, 'client-c') - cli.queue(String(connection.sent.at(-1)!.uuid)) - - expect(liveTurnId).not.toBe(olderTurnId) - await expect(namedStop(adapter, olderTurnId)).resolves.toEqual({ cancelled: false }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - expect(runningTurnId(bodies)).toBe(liveTurnId) - expect(settlements.filter((settlement) => settlement.clientMessageId === 'client-c')).toEqual( - [] - ) - }) - - it('withdraws a follow-up whose turn has not opened when the turn the Stop names already ended', async () => { - const settlements: Settlement[] = [] - const cli = claudeCli({ initProof: 'init', capabilities: CAPABILITIES }) - const { adapter, bodies, connection } = await acquired(cli.claude, settlements) - await sendAndOpen(adapter, connection, 'client-a') - const shownTurnId = runningTurnId(bodies) - await send(adapter, 'client-b') - cli.queue(String(connection.sent.at(-1)!.uuid)) - completeTurn(connection, 'result-a') - expect(openTurnId(bodies)).toBeNull() - - await expect(namedStop(adapter, shownTurnId)).resolves.toEqual({ cancelled: true }) - expect(connection.calls.find((call) => call.subtype === 'interrupt')?.params).toEqual({ - cancelQueued: true - }) - expect(settlements.filter((settlement) => settlement.clientMessageId === 'client-b')).toEqual( - WITHDRAWN - ) - }) -}) diff --git a/src/main/claude/structured/claude-structured-real-cli.test.ts b/src/main/claude/structured/claude-structured-real-cli.test.ts index 6549f072d82..9c9a3a56670 100644 --- a/src/main/claude/structured/claude-structured-real-cli.test.ts +++ b/src/main/claude/structured/claude-structured-real-cli.test.ts @@ -3,8 +3,6 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, join, relative } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { buildClaudeChildProcessEnv } from '../claude-child-process-environment' -import * as claudeConnection from '../claude-stream-json-connection' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session/journal/agent-session-journal-identity' import { resolveSessionFilePath } from '../../native-chat/session-file-resolver' import { @@ -126,53 +124,6 @@ async function waitForResolvedTranscript( } } -describe('real CLI fixture authentication isolation', () => { - it.each(['authenticated', 'signed-out'] as const)( - 'keeps the %s launch environment independent of inherited credentials', - async (mode) => { - const authKeys = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'] as const - for (const key of authKeys) { - vi.stubEnv(key, 'unusable-fixture-auth-value') - } - let childEnv: Record | undefined - const open = vi - .spyOn(claudeConnection, 'openClaudeStreamJsonConnection') - .mockImplementation(async (launch) => { - childEnv = buildClaudeChildProcessEnv(launch.env) - throw new Error('fixture stopped before spawning a CLI') - }) - const providerSessionId = randomUUID() - const adapter = realAdapter( - providerSessionId, - join(tmpdir(), 'orca-synthetic-signed-out'), - [], - process.cwd(), - undefined, - mode === 'signed-out' ? {} : undefined - ) - try { - await expect( - adapter.acquire({ - identity: identity(providerSessionId), - fence: 1, - spawnToken: 'fixture' - }) - ).rejects.toThrow('fixture stopped before spawning a CLI') - expect(open).toHaveBeenCalledOnce() - for (const key of authKeys) { - expect(childEnv?.[key]).toBe( - mode === 'authenticated' ? 'unusable-fixture-auth-value' : undefined - ) - } - } finally { - await adapter.closeAll() - open.mockRestore() - vi.unstubAllEnvs() - } - } - ) -}) - describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { it.skipIf(!realClaudeAuthenticated)( 'proves a pre-minted session before the first user message', @@ -353,62 +304,6 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 90_000 ) - it.skipIf(!realClaudeAuthenticated)( - 'opens with nothing sent, then answers a send handed over after it', - async () => { - const providerSessionId = randomUUID() - const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = realAdapter( - providerSessionId, - claudeConfigDir, - events, - process.cwd(), - undefined, - realClaudeLaunchHome().env, - false - ) - const until = async (done: () => boolean, timeoutMs: number): Promise => { - const deadline = Date.now() + timeoutMs - while (!done() && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 20)) - } - return done() - } - try { - const acquireAt = Date.now() - await adapter.acquire({ - identity: identity(providerSessionId), - fence: 1, - spawnToken: 'real-cli-started-timing' - }) - await claudeStartupSettled(adapter, 'real-cli-handshake') - const publishedAt = Date.now() - await claudeStartupSettled(adapter, 'real-cli-handshake') - const startedAt = Date.now() - const before = events.length - await adapter.dispatch({ - sessionId: 'real-cli-handshake', - clientMessageId: 'real-cli-started-timing-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Say ok' }] }, - fence: 1 - }) - expect( - await until(() => events.slice(before).some((event) => event.type === 'message'), 60_000) - ).toBe(true) - const firstOutputAt = Date.now() - console.info( - `[real-cli startup timing] acquire->published ${publishedAt - acquireAt} ms, ` + - `published->open ${startedAt - publishedAt} ms, ` + - `dispatch->first output ${firstOutputAt - startedAt} ms` - ) - } finally { - await adapter.closeAll() - } - }, - 120_000 - ) - it.skipIf(!realClaudeAuthenticated).each([ ['accept-edits', 'acceptEdits'], ['auto', 'auto'], diff --git a/src/main/claude/structured/claude-structured-session-adapter-turns.test.ts b/src/main/claude/structured/claude-structured-session-adapter-turns.test.ts deleted file mode 100644 index e2091a9ca20..00000000000 --- a/src/main/claude/structured/claude-structured-session-adapter-turns.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { ClaudeControlRequestError } from '../claude-agent-sdk-control-requests' -import { claudeUnwrittenUserMessageError } from '../claude-agent-sdk-user-message-queue' -import { - acquired, - fakeClaude, - PROVIDER_SESSION_ID, - USER_MESSAGE -} from './claude-structured-session-test-support' - -describe('ClaudeStructuredSessionAdapter turns and controls', () => { - it('applies a sent model and effort before handing Claude the message', async () => { - const claude = fakeClaude({ replayUuid: 'chosen-uuid' }) - const adapter = await acquired(claude) - const connection = claude.connections[0]! - const before = connection.calls.length - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'chosen-send', - body: USER_MESSAGE, - fence: 7, - executionOptions: { model: 'sonnet', effort: 'high' } - }) - ).resolves.toMatchObject({ state: 'admitted' }) - const controls = connection.calls.slice(before).map((call) => call.subtype) - expect(controls.indexOf('set_model')).toBeGreaterThan(-1) - expect(controls.indexOf('apply_flag_settings')).toBeGreaterThan(controls.indexOf('set_model')) - }) - - it("admits a dispatch on the write and names it from Claude's replay", async () => { - const claude = fakeClaude({ replayUuid: 'user-provider-uuid' }) - const settled = vi.fn() - const adapter = await acquired(claude, {}, [], settled) - - const result = await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - - expect(result).toMatchObject({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-1', - 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('does not put delivery in doubt while no replay uuid has arrived', async () => { - const settled = vi.fn() - const adapter = await acquired(fakeClaude({ replayUuid: null }), {}, [], settled) - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'admitted' }) - expect(settled).not.toHaveBeenCalled() - }) - - it('rejects a send whose frame the transport never took', async () => { - const claude = fakeClaude({ replayUuid: null }) - const adapter = await acquired(claude) - claude.connections[0]!.send = async () => { - throw claudeUnwrittenUserMessageError(new Error('broken pipe')) - } - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toEqual({ - state: 'rejected', - reason: 'provider_write_failed', - rejection: { kind: 'writeFailed' } - }) - }) - - 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(-3)).toEqual([ - { subtype: 'interrupt', params: {} }, - { subtype: 'set_model', params: { model: 'sonnet' } }, - { subtype: 'get_context_usage' } - ]) - - 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 - ) - }) - - // Session-scoped interrupt must remain available while a later send awaits its echo. - it.each([ - ['names the running turn', { turnId: 'turn-T' }], - ['names no turn', {}] - ])( - 'stops an acknowledged turn when the Stop %s, while a later send is unacknowledged', - async (_label, target) => { - const claude = fakeClaude({ replayUuids: ['turn-T', null] }) - const settled = vi.fn() - const adapter = await acquired(claude, {}, [], settled) - - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-T', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'admitted' }) - expect(settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-T', - providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: 'turn-T' } - }) - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-U', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'admitted' }) - expect(claude.connections[0].sent).toHaveLength(2) - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', fence: 7, ...target }) - ).resolves.toEqual({ cancelled: true }) - expect( - claude.connections[0].calls.filter((call) => call.subtype === 'interrupt') - ).toHaveLength(1) - } - ) - - 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('reports the running literal model without reading child capability choices', 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) - // Dispatch triggers the init frame carrying the model. - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'seed-cycle', - body: USER_MESSAGE, - fence: 7 - }) - - const { catalogListing, ...options } = await adapter.readOptions({ - sessionId: 'session-1', - fence: 7 - }) - expect(catalogListing).toBeUndefined() - expect(options.current).toEqual({ - model: 'claude-sonnet-5', - reportedModel: 'claude-sonnet-5', - effort: 'high', - reportedPermissionMode: undefined, - confirmed: ['model'] - }) - expect(options.models.some((model) => model.label === 'Default')).toBe(false) - }) - - 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) - // Dispatch triggers the init frame carrying the custom model. - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'seed-cycle', - body: USER_MESSAGE, - fence: 7 - }) - 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', - reportedModel: 'custom-model', - effort: 'high', - reportedPermissionMode: undefined, - confirmed: ['model'] - }) - }) -}) diff --git a/src/main/claude/structured/claude-structured-session-adapter.test.ts b/src/main/claude/structured/claude-structured-session-adapter.test.ts index 9a28982df51..9b45fff46e1 100644 --- a/src/main/claude/structured/claude-structured-session-adapter.test.ts +++ b/src/main/claude/structured/claude-structured-session-adapter.test.ts @@ -1,836 +1,9 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, onTestFinished, vi } from 'vitest' -import { AgentSessionAcquisitionRootExitObservedError } from '../../native-chat/agent-session-wire/structured-agent-session-acquisition-errors' -import { AgentSessionPromptAnswerRejectedError } from '../../native-chat/agent-session-wire/structured-agent-session-adapter-errors' -import type { ClaudeStreamJsonConnection } from '../claude-stream-json-connection' -import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV as SPAWN_TOKEN_ENV } from '../../../shared/agent-session/lifecycle/agent-session-caller-env' -import type { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { describe, expect, it } from 'vitest' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - acquired, - adapterFor, - adapterAtPublishFor, - fakeClaude, - identityFor, - PROVIDER_SESSION_ID, - tick, - USER_MESSAGE, - type FakeConnection -} from './claude-structured-session-test-support' -import { - claudeOpened, - claudeStartupSettled -} from './claude-structured-startup-settled-test-support' +import { acquired, fakeClaude, tick } from './claude-structured-session-test-support' import { invokeCanUseTool } from '../claude-can-use-tool-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' -import { providerRequestTimeoutOf } from '../../provider-process/provider-request-timeout' - -describe('ClaudeStructuredSessionAdapter.acquire', () => { - it('ends a stalled initialize at its 60-second deadline with the timed-out step', async () => { - vi.useFakeTimers() - const claude = fakeClaude({ initDelayMs: 60_001 }) - const adapter = adapterFor(claude) - let result: Promise | undefined - try { - let settled = false - result = adapter - .acquire({ identity: identityFor(), fence: 7, spawnToken: 'deadline' }) - .catch((error: unknown) => { - settled = true - return error - }) - await vi.advanceTimersByTimeAsync(59_999) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(1) - expect(providerRequestTimeoutOf(await result)).toEqual({ - method: 'initialize', - timeoutMs: 60_000 - }) - expect(claude.connections).toHaveLength(1) - } finally { - claude.dispose() - try { - await result - await adapter.closeAll() - } finally { - vi.useRealTimers() - } - } - }) - - 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, { continuesChain: true, resumesTranscript: false }, events) - - const acquisition = await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9' - }) - - expect(claude.connections[0].launch).toMatchObject({ - cwd: '/work/repo', - env: { - [SPAWN_TOKEN_ENV]: 'spawn-9', - CLAUDE_CONFIG_DIR: '/accounts/claude' - } - }) - 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((await claudeOpened(adapter, 'session-1')).link).toEqual({ - linkId: `claude-7-${PROVIDER_SESSION_ID}-empty`, - handle: claudeProviderHandle(PROVIDER_SESSION_ID, null), - origin: 'resumed', - mintedAtFence: 7, - observedAt: 1_700_000_000_500 - }) - expect((await claudeOpened(adapter, 'session-1')).newConversation).toBe(true) - expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started' } }) - }) - - it('launches a reacquired session with its persisted model and effort, and writes neither', async () => { - const claude = fakeClaude() - const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', effort: 'high' } - }) - - expect(claude.connections[0].launch.options).toMatchObject({ model: 'opus', effort: 'high' }) - expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([ - 'initialize', - 'get_settings' - ]) - await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ - current: { model: 'opus', effort: 'high' } - }) - }) - - it('launches a resumed conversation with its saved Fast as a flag setting', async () => { - const claude = fakeClaude({ - settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } - }) - const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - - expect(claude.connections[0].launch.options.settings).toEqual({ fastMode: true }) - expect( - claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') - ).toEqual([]) - }) - - it('does not carry a saved Fast into a new conversation whose settings opt in per session', async () => { - const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } } - }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, {}, events) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - - expect(claude.connections[0].launch.options.settings).toBeUndefined() - expect( - claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') - ).toEqual([]) - expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ - reportedOptions: { fastMode: true } - }) - }) - - it('leaves Fast for message input when the launch does not carry it', async () => { - const claude = fakeClaude({ - settings: { applied: {}, effective: {}, sources: {} }, - initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - }) - const adapter = adapterFor(claude) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - await claudeOpened(adapter, 'session-1') - - expect(claude.connections[0].launch.options.settings).toBeUndefined() - expect( - claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') - ).toEqual([]) - }) - - it('omits an invalid persisted effort at launch and uses the explicit start mode', async () => { - const claude = fakeClaude() - const adapter = adapterFor(claude) - const resolveLaunch = vi.spyOn(adapter['deps'], 'resolveLaunch') - const options = { effort: 'retired-effort', permissionMode: 'full-access' } - const start = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options } - - await adapter.acquire({ ...start, permissionMode: 'accept-edits' }) - expect(claude.connections[0].launch.options).not.toHaveProperty('effort') - expect(resolveLaunch.mock.calls[0]?.[0].permissionMode).toBe('accept-edits') - }) - - it('names a launch that could not be resolved on the host, keeping its error (#34795)', async () => { - const claude = fakeClaude() - const adapter = adapterFor(claude) - vi.spyOn(adapter['deps'], 'resolveLaunch').mockRejectedValueOnce( - new Error('selector_not_found') - ) - - await expect( - adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toMatchObject({ - name: 'AgentSessionPreSpawnError', - reason: 'launchPreparationFailed', - message: 'selector_not_found' - }) - expect(claude.connections).toHaveLength(0) - }) - - it('recovers a cancellable lifecycle when the replay arrives after dispatch returned', async () => { - const claude = fakeClaude({ replayUuid: null }) - const events: ClaudeStructuredSessionEvent[] = [] - const settled = vi.fn() - const adapter = await acquired(claude, {}, events, settled) - - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'admitted' }) - 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' }) - }) - ) - expect(settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-1', - providerIdentity: { - provider: 'claude', - sessionId: PROVIDER_SESSION_ID, - uuid: 'late-turn-1' - } - }) - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'late-turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) - }) - - it('opens each queued exact replay with its own request origin', async () => { - const claude = fakeClaude({ replayUuid: null }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) - const connection = claude.connections[0]! - const dispatch = async (clientMessageId: string, requestedAt: number): Promise => { - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId, - body: USER_MESSAGE, - fence: 7, - requestedAt - }) - ).resolves.toMatchObject({ state: 'admitted' }) - } - const echo = (index: number): void => { - const sent = connection.sent[index]! - connection.handlers.onMessage?.({ - ...sent, - uuid: `turn-${index + 1}`, - user_message_uuid: sent.uuid - }) - } - - await dispatch('client-a', 100) - echo(0) - await dispatch('client-b', 200) - await dispatch('client-c', 300) - echo(1) - echo(2) - - expect( - events - .filter((event) => event.type === 'message' && event.startsTurn === true) - .map((event) => (event.type === 'message' ? event.requestedAt : undefined)) - ).toEqual([100, 200, 300]) - }) - - it('quarantines SDK frames without the acquired session identity', async () => { - const claude = fakeClaude({ replayUuid: null }) - const events: ClaudeStructuredSessionEvent[] = [] - const settled = vi.fn() - const adapter = await acquired(claude, {}, events, settled) - 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' }] } - }) - - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'admitted' }) - expect(connection.sent).toHaveLength(1) - connection.handlers.onMessage?.({ - ...connection.sent[0], - uuid: 'foreign-replay', - session_id: 'foreign-provider-session' - }) - await Promise.resolve() - expect( - events.flatMap((event) => (event.type === 'message' ? [event.message.subtype] : [])) - ).toEqual(['hook_started', 'hook_response', 'init']) - expect(settled).not.toHaveBeenCalled() - - connection.handlers.onMessage?.({ - ...connection.sent[0], - session_id: PROVIDER_SESSION_ID - }) - expect(settled).toHaveBeenCalledWith({ - sessionId: 'session-1', - clientMessageId: 'client-1', - providerIdentity: { - provider: 'claude', - sessionId: PROVIDER_SESSION_ID, - 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', - [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', - [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({ [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, - [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) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9' - }) - - expect((await claudeOpened(adapter, 'session-1')).link?.handle).toEqual( - claudeProviderHandle(PROVIDER_SESSION_ID, 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, { - resumesTranscript: true, - continuesChain: true, - resumeLeafUuid: 'leaf-before' - }) - await resumed.acquire({ - identity: identityFor(), - fence: 9, - spawnToken: 'spawn-9' - }) - expect((await claudeOpened(resumed, 'session-1')).link?.origin).toBe('resumed') - expect((await claudeOpened(resumed, 'session-1')).link?.handle).toEqual( - claudeProviderHandle(PROVIDER_SESSION_ID, 'leaf-before') - ) - - const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) - const wrong = adapterAtPublishFor(wrongClaude) - await wrong.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - await expect(claudeOpened(wrong, 'session-1')).rejects.toThrow('provider session expected') - await wrong.closeSession('session-1') - expect(wrongClaude.connections[0].closeCount).toBe(1) - }) - - it('reports the CLI diagnostic when the child exits during open', async () => { - const claude = fakeClaude({ holdInit: true }) - const adapter = adapterAtPublishFor(claude) - let opening: Promise | undefined - onTestFinished(async () => { - claude.dispose() - await Promise.allSettled(opening ? [opening] : []) - await adapter.closeAll() - await adapter.drainObservedExits() - }) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - const connection = claude.connections[0] - const exited = vi.spyOn(connection.handlers, 'onExit') - const proof = vi.spyOn(connection.handlers, 'onMessage') - const first = connection.initializationResult() - expect(connection.initializationResult()).toBe(first) - opening = claudeOpened(adapter, 'session-1') - void opening.catch(() => {}) - connection.closed = true - connection.handlers.onExit?.(new Error('Claude login required')) - connection.initialization.reject(new Error('Query closed before response received')) - await expect(opening).rejects.toThrow('Claude login required') - await expect(first).rejects.toThrow('Query closed before response received') - expect(connection.initializationResult()).toBe(first) - expect(connection.sent).toEqual([]) - await adapter.drainObservedExits() - const reads = connection.calls.length - await connection.close() - expect(connection.initializationResult()).toBe(first) - expect(connection.calls).toHaveLength(reads + 1) - expect(exited).toHaveBeenCalledOnce() - expect(proof).not.toHaveBeenCalled() - }) - - it('refuses an unauthenticated initialize response even when SessionStart runs', async () => { - const claude = fakeClaude({ - initProof: 'session-start', - initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } - }) - const adapter = adapterAtPublishFor(claude) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('not signed in') - await adapter.closeSession('session-1') - expect(claude.connections[0].closeCount).toBe(1) - }) -}) - -describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { - 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.each(['unverifiable', 'live'] as const)( - 'classifies cleanup after a first-hand exit with its tree %s as a root exit, never as proven', - async (tree) => { - const { adapter, connection } = await exitedAfterPublish({ root: 'exited', tree }) - 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('forgets a retained exit once the session is acquired again', async () => { - const options: Parameters[0] = {} - const claude = fakeClaude(options) - const adapter = adapterAtPublishFor(claude, { - resumesTranscript: true, - continuesChain: true, - resumeLeafUuid: 'tip' - }) - let opening: Promise | undefined - onTestFinished(async () => { - claude.dispose() - await Promise.allSettled(opening ? [opening] : []) - await adapter.closeAll() - }) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - await claudeStartupSettled(adapter, 'session-1') - const first = claude.connections[0] - const proof = vi.spyOn(first.handlers, 'onMessage') - const response = first.initializationResult() - expect(first.initializationResult()).toBe(response) - expect(proof).not.toHaveBeenCalled() - first.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) - first.exitVerdict = { root: 'exited', tree: 'unverifiable' } - first.close = async () => false - options.holdInit = true - - const acquisition = adapter.acquire({ - identity: identityFor(), - fence: 8, - spawnToken: 'spawn-10' - }) - opening = acquisition.then(() => claudeOpened(adapter, 'session-1')) - void opening.catch(() => {}) - await acquisition - const second = claude.connections[1] - second.closed = true - second.handlers.onExit?.(new Error('claude stream-json exited (code 1): not logged in')) - second.initialization.reject(new Error('Query closed before response received')) - await expect(opening).rejects.toThrow('not logged in') - await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) - expect(first.closeCount).toBe(1) - expect(first.initializationResult()).toBe(response) - expect(proof).not.toHaveBeenCalled() - }) - - 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>() - .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('names a settled exit to a late caller until the chat is acquired again', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - claude.connections[0].handlers.onExit?.(new Error('claude stream-json exited: not logged in')) - await adapter.drainObservedExits() - expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toThrow('not logged in') - - await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) - expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 8 })).toThrow( - 'no live claude stream-json session' - ) - }) - - it('forgets an exit the chat was closed over, even one that settles during the close', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const connection = claude.connections[0] - const proof = Promise.withResolvers() - connection.close = vi - .fn() - .mockImplementationOnce(() => proof.promise) - .mockResolvedValue(true) - connection.handlers.onExit?.(new Error('claude stream-json exited: not logged in')) - await tick() - - const closing = adapter.closeSession('session-1') - proof.resolve(true) - await expect(closing).resolves.toBe(true) - expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toThrow( - 'no live claude stream-json session' - ) - }) - - it('starts the chat again after a crash whose root exited but whose descendants went unverified', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const first = claude.connections[0] - first.exitVerdict = { root: 'exited', tree: 'unverifiable' } - first.close = vi.fn().mockResolvedValue(false) - first.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) - - // Reacquire before queued exit publication to exercise synchronous settlement. - await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - - expect(claude.connections).toHaveLength(2) - }) - - it('publishes a crash whose root exited but whose descendants went unverified', async () => { - const claude = fakeClaude({ unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) - const connection = claude.connections[0] - connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) - await adapter.drainObservedExits() - - expect(events).toContainEqual( - expect.objectContaining({ type: 'ended', cause: 'unexpected-exit' }) - ) - await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) - }) - - it('forgets the stored crash when the host acknowledges session release', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - claude.connections[0].handlers.onExit?.(new Error('retained crash')) - await adapter.drainObservedExits() - expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toThrow( - 'retained crash' - ) - adapter.acknowledgeSessionRelease('session-1') - expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toThrow( - 'no live claude stream-json session' - ) - }) - - 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() - connection.close = vi - .fn<() => Promise>() - .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>() - .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', rules: [], behavior: 'allow', destination: 'session' }] - }) - 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', - response: { kind: 'option', optionId: 'allowForSession' }, - fence: 7, - commit: async () => undefined - }) - await expect(answered.promise).resolves.toEqual({ - behavior: 'allow', - updatedInput: { command: 'git status' }, - updatedPermissions: [ - { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } - ], - toolUseID: 'tool-1' - }) - }) - - it('settles the one AskUserQuestion callback from structured answers, including a long typed answer', 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-question', 'question-1') - const typed = 'Wait for the capture to finish first. '.repeat(60) - - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { - kind: 'answers', - answers: [ - { questionId: 'q1', optionIds: ['q1:choice-1'] }, - { questionId: 'q2', optionIds: [], other: typed } - ] - }, - fence: 7, - commit: async () => undefined - }) - await expect(answered.promise).resolves.toMatchObject({ - behavior: 'allow', - updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': typed.trim() } }, - toolUseID: 'tool-question' - }) - }) - - it('refuses answers Claude cannot take before the journal commits them', 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' }] }] } } - ) - adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') - const commit = vi.fn(async () => undefined) - - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit - }) - ).rejects.toBeInstanceOf(AgentSessionPromptAnswerRejectedError) - expect(commit).not.toHaveBeenCalled() - - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }, - fence: 7, - commit - }) - await expect(answered.promise).resolves.toMatchObject({ - updatedInput: { answers: { 'Library?': 'Luxon' } } - }) - }) - it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] diff --git a/src/main/claude/structured/claude-structured-session-commands.test.ts b/src/main/claude/structured/claude-structured-session-commands.test.ts deleted file mode 100644 index b731b554049..00000000000 --- a/src/main/claude/structured/claude-structured-session-commands.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { PrioritySemaphore } from '../../../shared/priority-semaphore' -import { describe, expect, it, vi } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../shared/agent-session/lifecycle/agent-session-record.test-fixture' -import { ClaudeAtRestCommandCatalog } from '../claude-at-rest-commands' -import { - bindAtRestCommandContexts, - retireAtRestCommandSettings -} from '../../native-chat/agent-session-wire/at-rest-command-context' -import { - adapterFor, - fakeClaude, - identityFor, - tick, - PROVIDER_SESSION_ID -} from './claude-structured-session-test-support' - -describe('session command updates', () => { - it('publishes changed catalogs exactly once while idle', async () => { - const claude = fakeClaude() - const changed = vi.fn() - const adapter = adapterFor(claude) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: changed - } - }) - changed.mockClear() - expect(adapter.readCommands('session-1')).toBeUndefined() - const frame = { - type: 'system', - subtype: 'commands_changed', - session_id: PROVIDER_SESSION_ID, - slash_commands: ['plugin:check', 'doctor'], - skills: ['plugin:check'], - terminal_slash_commands: ['doctor'] - } - claude.connections[0].handlers.onMessage?.(frame) - await tick() - expect(adapter.readCommands('session-1')).toEqual([ - { name: 'plugin:check', kind: 'skill' }, - { name: 'doctor', kind: 'command' } - ]) - expect(changed).toHaveBeenCalledTimes(1) - claude.connections[0].handlers.onMessage?.(frame) - await tick() - expect(changed).toHaveBeenCalledTimes(1) - claude.connections[0].handlers.onMessage?.({ ...frame, slash_commands: [] }) - await tick() - expect(adapter.readCommands('session-1')).toEqual([]) - expect(changed).toHaveBeenCalledTimes(2) - await adapter.closeSession('session-1') - }) -}) - -it.each([ - { commands: [] }, - { commands: [{ name: 'project:check', description: 'Project command', argumentHint: '' }] } -])('seeds the pre-prompt catalog from control initialization: %j', async ({ commands }) => { - const claude = fakeClaude({ initProof: 'session-start', initCommands: commands }) - const adapter = adapterFor(claude) - try { - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - const described = commands[0]?.description - expect(adapter.readCommands('session-1')).toEqual( - commands.map(({ name, description }) => ({ - name, - kind: 'command', - kindUnspecified: true, - ...(description ? { description } : {}) - })) - ) - expect(claude.connections[0].sent).toEqual([]) - expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ - 'initialize', - 'get_settings' - ]) - claude.connections[0].handlers.onMessage?.({ - type: 'system', - subtype: 'init', - session_id: PROVIDER_SESSION_ID, - slash_commands: ['project:check'], - skills: ['project:check'] - }) - expect(adapter.readCommands('session-1')).toEqual([ - { name: 'project:check', kind: 'skill', ...(described ? { description: described } : {}) } - ]) - } finally { - await adapter.closeSession('session-1') - } -}) - -it('keeps a buffered stream catalog newer than the initialization response', async () => { - const claude = fakeClaude({ initProof: 'session-start', initCommands: [{ name: 'old' }] }) - const open = claude.openConnection - claude.openConnection = async (...args) => { - const connection = await open(...args) - const getSettings = connection.getSettings - connection.getSettings = async (...settingsArgs) => { - args[1]?.onMessage?.({ - type: 'system', - subtype: 'commands_changed', - session_id: PROVIDER_SESSION_ID, - commands: [{ name: 'fresh' }] - }) - return getSettings(...settingsArgs) - } - return connection - } - const adapter = adapterFor(claude) - try { - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - expect(adapter.readCommands('session-1')?.map(({ name }) => name)).toEqual(['fresh']) - } finally { - await adapter.closeSession('session-1') - } -}) - -it("answers a resting chat's `/` menu only for chats this host runs", async () => { - const listed = [{ name: 'review-pr', kind: 'skill' as const }] - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => process.cwd(), - probe: async () => listed - }) - const local = agentSessionRecordFixture() - catalog.read(local) - await vi.waitFor(() => expect(catalog.read(local)).toEqual(listed)) - - // Same workspace and account on another host: only that host can read its folders. - const remote = { ...local, location: { ...local.location, executionHostId: 'ssh:box' as const } } - expect(catalog.read(remote)).toBeUndefined() -}) - -it("lists a resting chat's `/` menu from the account it would start on, following a switch", async () => { - let selected = '/accounts/b' - const probed: string[] = [] - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => process.cwd(), - resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: selected }), - probe: async ({ accountHomePath }) => { - probed.push(accountHomePath) - return [{ name: accountHomePath, kind: 'skill' }] - } - }) - const release = bindAtRestCommandContexts([catalog]) - try { - // Created on /home/user/.claude, but its next start uses the selected account. - const chat = agentSessionRecordFixture() - expect(await catalog.settled(chat, 1000)).toEqual([{ name: '/accounts/b', kind: 'skill' }]) - const createdOnB = { - ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'created-on-b' })), - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/b' } - } - expect(await catalog.settled(createdOnB, 1000)).toEqual([ - { name: '/accounts/b', kind: 'skill' } - ]) - expect(probed).toEqual(['/accounts/b']) - - selected = '/accounts/a' - retireAtRestCommandSettings(['activeClaudeManagedAccountId']) - expect(await catalog.settled(chat, 1000)).toEqual([{ name: '/accounts/a', kind: 'skill' }]) - } finally { - release() - } -}) - -it('withdraws a queued legacy acquisition for modern demand without duplicating its attempt', async () => { - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) - const acquiring = vi.spyOn(PrioritySemaphore.prototype, 'acquire') - const starts: string[] = [] - const held = Promise.withResolvers() - let active = 0, - peak = 0 - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => process.cwd(), - probe: async ({ cwd, signal }) => { - starts.push(cwd) - peak = Math.max(peak, ++active) - try { - await new Promise((resolve) => { - held.promise.then(resolve) - signal?.addEventListener('abort', () => held.promise.then(resolve), { once: true }) - }) - return [{ name: 'fast', kind: 'command' }] - } finally { - active-- - } - } - }) - const record = agentSessionRecordFixture() - const chat = (workspaceId: string) => ({ - ...record, - location: { ...record.location, workspaceId } - }) - try { - for (const id of ['one', 'two']) { - catalog.read(chat(id)) - } - await vi.waitFor(() => expect(starts).toHaveLength(2)) - catalog.read(chat('queued')) - await vi.waitFor(() => expect(acquiring).toHaveBeenCalledTimes(3)) - const menu = catalog.settled(chat('queued'), 10_000, true) - const joined = catalog.settled(chat('queued'), 10_000, true) - await vi.waitFor(() => expect(starts).toHaveLength(3)) - expect(peak).toBe(3) - catalog.dispose() - // Logical retirement does not make held physical work disappear. - expect(active).toBe(3) - held.resolve() - await Promise.all([menu, joined]) - await vi.waitFor(() => expect(active).toBe(0)) - expect(starts).toHaveLength(3) - } finally { - held.resolve() - catalog.dispose() - acquiring.mockRestore() - vi.useRealTimers() - } -}) diff --git a/src/main/claude/structured/claude-structured-session-reading-control.test.ts b/src/main/claude/structured/claude-structured-session-reading-control.test.ts deleted file mode 100644 index c3cb91f30fe..00000000000 --- a/src/main/claude/structured/claude-structured-session-reading-control.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody -} from '../../../shared/agent-session/journal/agent-session-journal-types' -import { describe, expect, it, vi } from 'vitest' -import { - createDeferredStructuredAgentSessionEventSink, - type StructuredAgentSessionEventTarget, - type StructuredAgentSessionEventSink, - type StructuredAgentSessionReadingControl -} from '../../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionLinkageJournal } from '../../native-chat/agent-session-wire/structured-agent-session-journal-views' -import { agentJournalItemKey } from '../../../shared/agent-session/journal/agent-session-journal-item-key' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session/journal/agent-session-journal-identity' -import type { AgentSessionJournal } from '../../native-chat/agent-session-journal/journal-store' -import { blockOf } from '../claude-background-task-row-test-support' -import { - adapterFor, - fakeClaude, - identityFor, - PROVIDER_SESSION_ID -} from './claude-structured-session-test-support' -import { withJournalQueueMembers } from '../../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' -import { testEventSinkLogging } from '../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' - -function controlledSink(): { - sink: StructuredAgentSessionEventSink - control: () => StructuredAgentSessionReadingControl | undefined - unbind: ReturnType -} { - let control: StructuredAgentSessionReadingControl | undefined - const unbind = vi.fn() - return { - sink: { - appendItem: vi.fn(), - appendTombstone: vi.fn(), - publish: vi.fn(), - bindReadingControl: (next) => { - control = next - return unbind - } - }, - control: () => control, - unbind - } -} - -function persistedTarget( - persisted: Map -): StructuredAgentSessionEventTarget { - const journal = - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test double implements the journal methods exercised by the deferred sink. - withJournalQueueMembers({ - appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - persisted.set(agentJournalItemKey(identity), body) - return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 } - }, - appendTombstone: vi.fn(), - visitItems: ( - visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void - ) => { - for (const [itemId, body] of persisted) { - visit(itemId, 0, body) - } - }, - // This double keeps no producer linkage, so every row reads as the session's own. - visitItemsWithLinkage: ((visit) => { - persisted.forEach((body, itemId) => visit(itemId, 0, body, {})) - }) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'], - itemBody: (itemId: string) => persisted.get(itemId) ?? null, - threadGoal: () => null, - submissions: () => [], - threadGoalSource: () => ({ goal: null, row: null }), - epoch: 'test' - }) as unknown as AgentSessionJournal - return { journal, fence: 1, publish: vi.fn() } -} - -describe('Claude structured reading control', () => { - it('unbinds when acquisition fails after the connection opens', async () => { - const claude = fakeClaude() - const open = claude.openConnection - claude.openConnection = async (launch, handlers = {}) => { - const connection = await open(launch, handlers) - claude.connections[0].closed = true - handlers.onExit?.(new Error('claude stream-json exited (code 1)')) - return connection - } - const adapter = adapterFor(claude) - const events = controlledSink() - - await expect( - adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: events.sink - }) - ).rejects.toThrow('exited (code 1)') - expect(events.unbind).toHaveBeenCalledOnce() - }) - - it('keeps delivery ownership until a reported provider exit finishes closing', async () => { - const claude = fakeClaude() - const adapter = adapterFor(claude) - const events = controlledSink() - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: events.sink - }) - const connection = claude.connections[0] - - connection.handlers.onExit?.(new Error('provider exited')) - connection.handlers.onMessage?.({ - type: 'system', - subtype: 'task_notification', - session_id: PROVIDER_SESSION_ID, - task_id: 'held-terminal-frame', - status: 'failed', - summary: 'The final task outcome' - }) - - expect(events.sink.appendItem).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - kind: 'message', - role: 'system', - blocks: expect.arrayContaining([ - expect.objectContaining({ - type: 'background-task', - taskId: 'held-terminal-frame', - summary: 'The final task outcome' - }) - ]) - }), - expect.anything() - ) - await adapter.drainObservedExits() - expect(events.unbind).toHaveBeenCalledOnce() - }) - - it('releases SDK reading when a pending row becomes permanently refused', async () => { - const claude = fakeClaude() - const adapter = adapterFor(claude) - const events = controlledSink() - events.sink.tryAppendResolvedItemAndPublish = vi - .fn() - .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) - .mockReturnValueOnce({ accepted: false, reason: 'failed' }) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: events.sink - }) - const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading') - claude.connections[0].handlers.onMessage?.({ - type: 'system', - subtype: 'task_notification', - session_id: PROVIDER_SESSION_ID, - task_id: 'failed-journal-row', - status: 'failed', - summary: 'failed' - }) - - events.control()?.pauseReading() - events.control()?.resumeReading() - - expect(resumeReading).toHaveBeenCalledOnce() - await adapter.drainObservedExits() - }) - - it('admits a task row past the pause watermark and resumes SDK reads after the journal drains', async () => { - const persisted = new Map() - const target = persistedTarget(persisted) - const deferred = createDeferredStructuredAgentSessionEventSink({ - ...testEventSinkLogging(), - watermarks: { pauseQueuedOperations: 1, lowQueuedOperations: 0 } - }) - deferred.bind(target) - const claude = fakeClaude() - const adapter = adapterFor(claude) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: deferred.sink - }) - await deferred.drained() - persisted.clear() - - const appendEntered = Promise.withResolvers() - const appendGate = Promise.withResolvers() - const appendItem = target.journal.appendItem.bind(target.journal) - vi.spyOn(target.journal, 'appendItem').mockImplementationOnce(async (...args) => { - appendEntered.resolve() - await appendGate.promise - return appendItem(...args) - }) - const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading') - deferred.sink.appendItem( - { provider: 'orca', clientMessageId: 'blocked-prefill' }, - { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await appendEntered.promise - const notification = { - type: 'system', - subtype: 'task_notification', - session_id: PROVIDER_SESSION_ID, - task_id: 'hard-watermark-task', - tool_use_id: 'toolu-hard-watermark', - status: 'failed', - summary: 'The real provider task failed', - uuid: 'hard-watermark-notification' - } - claude.connections[0].handlers.onMessage?.(notification) - claude.connections[0].handlers.onMessage?.(notification) - expect(deferred.state().backpressured).toBe(true) - - appendGate.resolve() - await vi.waitFor(() => expect(resumeReading).toHaveBeenCalledOnce()) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - const taskRows = [...persisted.values()].filter( - (body) => body.kind === 'message' && blockOf(body)?.taskId === 'hard-watermark-task' - ) - expect(taskRows).toHaveLength(1) - expect(blockOf(taskRows[0])?.summary).toBe('The real provider task failed') - expect( - [...persisted.values()].some( - (body) => - body.kind === 'status' && body.providerFrame?.kind.includes('task_notification') === true - ) - ).toBe(false) - await expect(adapter.closeSession('session-1')).resolves.toBe(true) - }) -}) diff --git a/src/main/claude/structured/claude-structured-session-recovery.test.ts b/src/main/claude/structured/claude-structured-session-recovery.test.ts deleted file mode 100644 index b804a0ee2e6..00000000000 --- a/src/main/claude/structured/claude-structured-session-recovery.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { StructuredAgentSessionEventSink } from '../../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -import type { - ClaudeStructuredSessionAdapterDeps, - ClaudeStructuredSessionEvent -} from './claude-structured-session-state' -import { - adapterFor, - fakeClaude, - identityFor, - PROVIDER_SESSION_ID, - tick, - recordingJournalSink -} from './claude-structured-session-test-support' -import { - claudeStartupSettled, - claudeOpened -} from './claude-structured-startup-settled-test-support' -import { invokeCanUseTool } from '../claude-can-use-tool-test-support' -import { - claudeProviderHandle, - claudeProviderHandleLeafUuid -} from '../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' - -describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { - it('shares concurrent close finalization and emits lifecycle once', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const persistence = Promise.withResolvers() - const persistHandle = vi.fn(() => persistence.promise) - const adapter = adapterFor(claude, {}, events, [], 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 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, - resumesTranscript: false, - cliFlag: async () => 'supported' as const, - continuesChain: 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 void } | null }> - } - ).sessions.get('session-1') - const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') - - await expect(adapter.closeSession('session-1')).resolves.toBe(true) - await vi.waitFor(() => - expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) - ) - expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) - expect(disposeTranslator).toHaveBeenCalledOnce() - }) - - it('does not keep a dead child indexed over a failed resume-point write', async () => { - const claude = fakeClaude() - const persistenceError = new Error('store unavailable') - const persistHandle = vi - .fn>() - .mockRejectedValueOnce(persistenceError) - .mockResolvedValueOnce(undefined) - const adapter = adapterFor(claude, {}, [], [], undefined, persistHandle) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - - await expect(adapter.closeSession('session-1')).resolves.toBe(true) - await vi.waitFor(() => expect(persistHandle).toHaveBeenCalledOnce()) - await expect(adapter.closeSession('session-1')).resolves.toBe(true) - expect(persistHandle).toHaveBeenCalledOnce() - }) - - it('persists the last completed turn message 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', - events: { ...recordingJournalSink(), written: async () => ({ ok: true }) } - }) - claude.connections[0].handlers.onMessage?.({ - type: 'assistant', - session_id: PROVIDER_SESSION_ID, - uuid: 'assistant-leaf', - message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }] } - }) - 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') - - await vi.waitFor(() => expect(persistedHandles).toHaveLength(1)) - expect(persistedHandles).toEqual([ - { - sessionId: 'session-1', - providerSessionId: PROVIDER_SESSION_ID, - leafUuid: 'assistant-leaf', - fence: 7 - } - ]) - expect(events.at(-2)).toMatchObject({ type: 'ended', cause: 'requested-close' }) - expect(events.at(-1)).toEqual({ - type: 'handle', - sessionId: 'session-1', - providerSessionId: PROVIDER_SESSION_ID, - leafUuid: 'assistant-leaf', - fence: 7 - }) - expect(claude.connections[0].closeCount).toBe(1) - }) - - it('persists the last completed turn, not a half-turn prompt, on an unexpected 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', - events: { ...recordingJournalSink(), written: async () => ({ ok: true }) } - }) - const frame = (message: Record) => - claude.connections[0].handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) - frame({ - type: 'assistant', - uuid: 'crash-leaf', - message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }] } - }) - frame({ type: 'result', uuid: 'result-frame-uuid' }) - frame({ - type: 'user', - uuid: 'half-turn-prompt', - message: { role: 'user', content: 'next' } - }) - - 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('publishes lifecycle recovery even when crash-cursor persistence fails', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor( - claude, - {}, - events, - [], - 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>() - .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>() - .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>() - .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 turn-end cursor', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const persistedHandles: unknown[] = [] - const journalSink: StructuredAgentSessionEventSink = { - appendItem: () => {}, - appendTombstone: () => {}, - publish: () => {}, - written: async () => ({ ok: true }) - } - let durableLeafUuid: string | null = null - const resolveLaunch = vi.fn(async ({ identity }) => { - if ( - !identity.providerHandle || - identity.providerHandle.nativeId !== PROVIDER_SESSION_ID || - claudeProviderHandleLeafUuid(identity.providerHandle) !== 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, - resumesTranscript: false, - cliFlag: async () => 'supported' as const, - continuesChain: false - } - } - return { - pathToClaudeCodeExecutable: 'claude', - options: { resume: PROVIDER_SESSION_ID }, - cwd: '/work/repo', - claudeConfigDir: '/accounts/claude', - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: durableLeafUuid, - resumesTranscript: true, - cliFlag: async () => 'supported' as const, - continuesChain: true - } - }) - const persistHandle = vi.fn>( - 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, - persistHandle - }) - const firstAcquisition = await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: journalSink - }) - await claudeStartupSettled(adapter, 'session-1') - 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', - message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }] } - }) - first.handlers.onMessage?.({ type: 'result', session_id: PROVIDER_SESSION_ID }) - first.close = vi - .fn<() => Promise>() - .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([]) - - await adapter.acquire({ - identity: { - ...identityFor(), - providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf') - }, - fence: 8, - spawnToken: 'spawn-10', - events: journalSink - }) - const replacement = await claudeOpened(adapter, 'session-1') - - expect(disposeTranslator).toHaveBeenCalledOnce() - expect(settlePrompt).toHaveBeenCalledOnce() - expect(settlePrompt).toHaveBeenCalledWith(null) - expect(persistHandle).toHaveBeenCalledOnce() - expect(persistedHandles).toEqual([ - { - sessionId: 'session-1', - providerSessionId: PROVIDER_SESSION_ID, - leafUuid: 'observed-retained-leaf', - fence: 7 - } - ]) - expect(resolveLaunch).toHaveBeenNthCalledWith(2, { - identity: { - ...identityFor(), - providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf') - }, - spawnToken: expect.any(String) - }) - expect(oldPrompt.settled()).toBe(true) - expect(events.filter((event) => event.type === 'ended')).toEqual([ - { - type: 'ended', - sessionId: 'session-1', - reason: 'crashed before replacement', - failure: { kind: 'providerExited' }, - cause: 'unexpected-exit', - fence: 7, - acquisitionGeneration: firstAcquisition.acquisitionGeneration, - observedAt: 1_700_000_000_500 - } - ]) - expect(replacement.link).toMatchObject({ - handle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf'), - origin: 'resumed', - mintedAtFence: 8 - }) - expect(claude.connections[1]?.launch.options).toEqual({ resume: PROVIDER_SESSION_ID }) - expect(claude.connections).toHaveLength(2) - first.handlers.onMessage?.({ - type: 'assistant', - session_id: PROVIDER_SESSION_ID, - uuid: 'stale-old-leaf', - message: { role: 'assistant', content: [{ type: 'text', text: 'stale answer' }] } - }) - first.handlers.onMessage?.({ type: 'result', session_id: PROVIDER_SESSION_ID }) - await tick() - expect((await claudeOpened(adapter, 'session-1')).link).toEqual(replacement.link) - expect(persistHandle).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/claude/structured/claude-structured-session-startup-failure.test.ts b/src/main/claude/structured/claude-structured-session-startup-failure.test.ts deleted file mode 100644 index e80f775ee6d..00000000000 --- a/src/main/claude/structured/claude-structured-session-startup-failure.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -import sdkExit from '../fixtures/startup-sdk-process-exit.json' -import { afterEach, expect, it, vi } from 'vitest' -import { findInErrorChain } from '../../provider-process/provider-error-chain' -import { withMissingProviderExecutable } from '../../provider-process/provider-executable-missing' -import { claudeStartupAnswerObservation } from '../claude-startup-answer-observation' -import { runClaudeControl } from '../claude-agent-sdk-control-requests' -import { withObservedProviderExit } from '../../native-chat/agent-session-wire/structured-agent-session-failure-text' -import { - PROVIDER_SESSION_ID, - adapterAtPublishFor, - adapterFor, - fakeClaude, - identityFor, - recordingJournalSink, - tick -} from './claude-structured-session-test-support' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -import { NDJSON_MAX_LINE_BYTES } from '../../../shared/main-process-ndjson-framer' -import { claudeAcquireFailure, claudeStartFailure } from '../claude-startup-failure' -import { retryableAgentSessionAcquisition } from '../../native-chat/agent-session-wire/structured-agent-session-start-failure' - -const retryableStructuredAgentSessionStart = (error: unknown): boolean => - claudeStartFailure(error).retry === 'safe' -const retryableStructuredAgentSessionAcquisition = (error: unknown): boolean => - retryableAgentSessionAcquisition(claudeAcquireFailure(error)) - -afterEach(() => vi.useRealTimers()) - -it.each(['control_request_failed', 'process_exited_nonzero'])( - 'preserves the independently pending SDK %s outcome when exit proof wins first', - async (errorClass) => { - vi.useFakeTimers() - const claude = fakeClaude({ holdInit: true }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterAtPublishFor(claude, {}, events) - const acquired = await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'race' - }) - const connection = claude.connections[0] - const pending = Promise.withResolvers() - connection.initializationResult = () => - runClaudeControl('initialize', () => pending.promise, null) - // Delay SDK cleanup, independently of the actual process exit. - connection.close = () => new Promise(() => {}) - const result = adapter - .open({ - sessionId: 'session-1', - fence: 7, - acquisitionGeneration: acquired.acquisitionGeneration, - signal: new AbortController().signal - }) - .catch((error) => error) - connection.closed = true - connection.exitVerdict = { root: 'exited', tree: 'exited' } - const exit = withObservedProviderExit(new Error('claude exited')) - connection.handlers.onExit?.(exit, { expected: false }) - await vi.advanceTimersByTimeAsync(10) - const recorded = sdkExit.records[0].initializationError - if (!recorded) { - throw new Error('missing recorded SDK exit') - } - pending.reject( - errorClass === 'process_exited_nonzero' - ? Object.assign(new Error(recorded.message), recorded) - : Object.assign(new Error('SDK terminal outcome'), { errorClass, exitCode: 1 }) - ) - await vi.advanceTimersByTimeAsync(2_000) - const error = await result - // The SDK's report of the exit is no answer, so Orca's own exit record decides the start. - if (errorClass === 'process_exited_nonzero') { - expect(error.cause).toBe(exit) - } else { - expect(error.cause.cause).toMatchObject({ errorClass, exitCode: 1 }) - } - expect(retryableStructuredAgentSessionStart(error)).toBe( - errorClass === 'process_exited_nonzero' - ) - expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) - claude.dispose() - await adapter.closeAll() - } -) - -it('retains a failed result before a stalled SDK transcript flush and never waits on startup cursor persistence', async () => { - vi.useFakeTimers() - const claude = fakeClaude({ holdInit: true }) - const persist = vi.fn(() => new Promise(() => {})) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterAtPublishFor(claude, {}, events, [], undefined, persist) - const acquired = await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'flush' }) - const connection = claude.connections[0] - connection.close = () => new Promise(() => {}) - const observation = claudeStartupAnswerObservation() - connection.startupFailure = observation.failure - const result = adapter - .open({ - sessionId: 'session-1', - fence: 7, - acquisitionGeneration: acquired.acquisitionGeneration, - signal: new AbortController().signal - }) - .catch((error) => error) - observation.feed( - `${JSON.stringify({ - type: 'result', - is_error: true, - subtype: 'error_during_execution', - errors: ['startup refused'] - })}\n` - ) - connection.closed = true - connection.exitVerdict = { root: 'exited', tree: 'exited' } - connection.handlers.onExit?.(withObservedProviderExit(new Error('exited')), { expected: false }) - await vi.advanceTimersByTimeAsync(2_100) - const error = await result - expect(error.cause.errorClass).toBe('error_result') - expect(retryableStructuredAgentSessionStart(error)).toBe(false) - expect(persist).not.toHaveBeenCalled() - expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) - claude.dispose() - await adapter.closeAll() -}) - -it('treats a turn that settled without an Orca dispatch as past startup on 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: 'undispatched', - events: { ...recordingJournalSink(), written: async () => ({ ok: true }) } - }) - const connection = claude.connections[0] - // No send ran, so the startup observer still holds a later failed result. - connection.startupFailure = () => - Object.assign(new Error('claude reported a failed result during startup'), { - errorClass: 'error_result' - }) - const frame = (message: Record) => - connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) - frame({ - type: 'assistant', - uuid: 'undispatched-leaf', - message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }] } - }) - frame({ type: 'result' }) - connection.handlers.onExit?.(withObservedProviderExit(new Error('exited')), { expected: false }) - await tick() - - expect(persistedHandles).toContainEqual( - expect.objectContaining({ leafUuid: 'undispatched-leaf' }) - ) - expect(events.at(-1)).toMatchObject({ type: 'ended', failure: { kind: 'providerExited' } }) - claude.dispose() - await adapter.closeAll() -}) - -it.each(['the SDK reports the exit first', "Orca's own exit arrives first"])( - "reads a missing CLI from Orca's exit when %s", - async (order) => { - const claude = fakeClaude({ holdInit: true }) - const adapter = adapterAtPublishFor(claude) - const acquired = await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'missing-cli' - }) - const connection = claude.connections[0] - const exited = Promise.withResolvers() - connection.exited = exited.promise - const report = Promise.withResolvers() - connection.initializationResult = () => - runClaudeControl('initialize', () => report.promise, null) - // A supervised spawn ENOENT: the supervisor exits 127 and the SDK reports only that exit. - const sdkReport = Object.assign(new Error('Claude Code process exited with code 127'), { - errorClass: 'process_exited_nonzero', - exitCode: 127 - }) - const ownExit = withMissingProviderExecutable( - withObservedProviderExit( - new Error('claude stream-json exited (code 127): spawn claude ENOENT') - ) - ) - const result = adapter - .open({ - sessionId: 'session-1', - fence: 7, - acquisitionGeneration: acquired.acquisitionGeneration, - signal: new AbortController().signal - }) - .catch((error) => error) - await tick() - if (order === 'the SDK reports the exit first') { - report.reject(sdkReport) - await tick() - exited.resolve(ownExit) - } else { - exited.resolve(ownExit) - connection.closed = true - connection.exitVerdict = { root: 'exited', tree: 'exited' } - connection.handlers.onExit?.(ownExit, { expected: false }) - await tick() - report.reject(sdkReport) - } - const error = await result - expect(error).toMatchObject({ retry: 'never', fact: { kind: 'cliMissing' } }) - // The verdict keeps Orca's record, diagnostic and absence marker, not the SDK's summary. - expect(findInErrorChain(error, (cause) => (cause === ownExit ? true : undefined))).toBe(true) - claude.dispose() - await adapter.closeAll() - } -) - -it('settles an unanswered Claude startup exit with the same bounded recovery eligibility', async () => { - vi.useFakeTimers() - const claude = fakeClaude({ holdInit: true }) - const adapter = adapterAtPublishFor(claude) - const acquired = await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'no-answer' - }) - const connection = claude.connections[0] - connection.close = () => new Promise(() => {}) - const result = adapter - .open({ - sessionId: 'session-1', - fence: 7, - acquisitionGeneration: acquired.acquisitionGeneration, - signal: new AbortController().signal - }) - .catch((error) => error) - const error = withObservedProviderExit(new Error('child exited before initialize')) - connection.closed = true - connection.exitVerdict = { root: 'exited', tree: 'exited' } - connection.handlers.onExit?.(error, { expected: false }) - await vi.advanceTimersByTimeAsync(2_100) - expect((await result).cause).toBe(error) - expect(retryableStructuredAgentSessionStart(await result)).toBe(true) - claude.dispose() - await adapter.closeAll() -}) - -it('keeps a rejected initialize final when the child exits before acquisition publishes', async () => { - const claude = fakeClaude({ holdInit: true }) - const probing = Promise.withResolvers() - const startTime = Promise.withResolvers() - const adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: '/work/repo', - claudeConfigDir: '/accounts/claude', - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - resumesTranscript: false, - cliFlag: async () => 'supported' as const, - continuesChain: false - }), - openConnection: claude.openConnection, - readProcessStartTime: async () => { - probing.resolve() - return startTime.promise - } - }) - const result = adapter - .acquire({ identity: identityFor(), fence: 7, spawnToken: 'prepublication' }) - .catch((error) => error) - await probing.promise - const connection = claude.connections[0] - // The SDK's answer to an error control_response for initialize, as the published path sees it. - const answer = runClaudeControl( - 'initialize', - () => - Promise.reject( - Object.assign(new Error('initialize rejected'), { errorClass: 'control_request_failed' }) - ), - null - ) - connection.initializationResult = () => answer - connection.closed = true - connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } - connection.close = async () => false - connection.handlers.onExit?.(withObservedProviderExit(new Error('claude exited (code 1)')), { - expected: false - }) - startTime.resolve(1_700_000_000_000) - const error = await result - expect( - findInErrorChain(error, (cause) => ('errorClass' in cause ? cause.errorClass : undefined)) - ).toBe('control_request_failed') - expect(retryableStructuredAgentSessionAcquisition(error)).toBe(false) - claude.dispose() - connection.close = async () => true - await adapter.closeAll() -}) - -it('parses only failed root results at startup, bounded by candidates rather than records', async () => { - const flush = () => new Promise((resolve) => setImmediate(resolve)) - const hook = (output: string) => - `${JSON.stringify({ - type: 'system', - subtype: 'hook_response', - hook_name: 'SessionStart:startup', - hook_event: 'SessionStart', - output, - stdout: '', - stderr: '' - })}\n` - const assistant = (text: string) => - `${JSON.stringify({ - type: 'assistant', - parent_tool_use_id: null, - message: { role: 'assistant', content: [{ type: 'text', text }] } - })}\n` - const failedResult = (subtype: string) => - `${JSON.stringify({ type: 'result', subtype, is_error: true })}\n` - // Counts the real observer's work for one child at a given startup record population. - const observeStartup = async (records: number) => { - const observation = claudeStartupAnswerObservation() - const parse = vi.spyOn(JSON, 'parse') - try { - const oversized = hook('x'.repeat(NDJSON_MAX_LINE_BYTES + 1)) - observation.feed(oversized.slice(0, NDJSON_MAX_LINE_BYTES / 2)) - observation.feed(oversized.slice(NDJSON_MAX_LINE_BYTES / 2)) - for (let index = 0; index < records; index += 1) { - observation.feed( - index % 2 === 0 - ? hook(JSON.stringify({ type: 'result', is_error: true, index })) - : assistant(`working ${index}`) - ) - } - // Later failed results coalesced into the capturing chunk must not be parsed. - observation.feed( - failedResult('error_during_execution') + - failedResult('error_in_capturing_chunk').repeat(records) - ) - await flush() - const parsed = parse.mock.calls.map(([text]) => String(text)) - const failure = observation.failure() - parse.mockClear() - observation.feed(failedResult('error_after_capture')) - observation.feed(hook('after capture')) - observation.finish() - observation.feed(failedResult('error_after_finish')) - await flush() - return { - irrelevantParses: parsed.filter( - (text) => !text.includes('error_during_execution') && !text.includes('capturing_chunk') - ).length, - candidateParses: parsed.filter((text) => text.includes('error_during_execution')).length, - sameChunkParsesAfterCapture: parsed.filter((text) => text.includes('capturing_chunk')) - .length, - failure, - parsesAfterCapture: parse.mock.calls.length - } - } finally { - parse.mockRestore() - } - } - const n = await observeStartup(100) - const tenN = await observeStartup(1000) - expect(n).toMatchObject({ - irrelevantParses: 0, - candidateParses: 1, - sameChunkParsesAfterCapture: 0, - failure: expect.objectContaining({ errorClass: 'error_result' }), - parsesAfterCapture: 0 - }) - expect(tenN).toEqual(n) -}) - -it('takes no startup answer from a line over the budget', async () => { - const observation = claudeStartupAnswerObservation() - const flush = () => new Promise((resolve) => setImmediate(resolve)) - const large = 'x'.repeat(NDJSON_MAX_LINE_BYTES + 1) - // Tool input is arbitrary JSON; a result-shaped object inside it is not the provider's answer. - const assistant = `${JSON.stringify({ - type: 'assistant', - parent_tool_use_id: null, - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool-1', - name: 'Write', - input: { type: 'result', is_error: true, content: large } - } - ] - } - })}\n` - observation.feed(assistant.slice(0, NDJSON_MAX_LINE_BYTES)) - observation.feed(assistant.slice(NDJSON_MAX_LINE_BYTES)) - await flush() - expect(observation.failure()).toBeUndefined() - // The parent that marks a subagent result can sit past the retained prefix. - observation.feed( - `${JSON.stringify({ type: 'result', is_error: true, errors: [large], parent_tool_use_id: 'task-1' })}\n` - ) - // Intended limit: an oversized failed root result is left to the SDK answer or the unanswered exit. - observation.feed(`${JSON.stringify({ type: 'result', is_error: true, errors: [large] })}\n`) - await flush() - expect(observation.failure()).toBeUndefined() - observation.feed( - `${JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true })}\n` - ) - await flush() - expect(observation.failure()).toMatchObject({ errorClass: 'error_result' }) - const parse = vi.spyOn(JSON, 'parse') - try { - observation.feed(`${JSON.stringify({ type: 'result', subtype: 'later', is_error: true })}\n`) - expect(parse).not.toHaveBeenCalled() - } finally { - parse.mockRestore() - } -}) diff --git a/src/main/claude/structured/claude-structured-session-startup.test.ts b/src/main/claude/structured/claude-structured-session-startup.test.ts index 7ab47856e48..dfb39fdfeff 100644 --- a/src/main/claude/structured/claude-structured-session-startup.test.ts +++ b/src/main/claude/structured/claude-structured-session-startup.test.ts @@ -5,19 +5,12 @@ import { fakeClaude, claudeFrame, identityFor, - USER_MESSAGE, recordingJournalSink, PROVIDER_SESSION_ID } from './claude-structured-session-test-support' import { claudeOpened } from './claude-structured-startup-settled-test-support' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { CLAUDE_INITIALIZE_TIMEOUT_MS } from './claude-structured-session-startup' -import { providerRequestTimeoutOf } from '../../provider-process/provider-request-timeout' import type { ClaudeStructuredLaunch } from './launch/claude-structured-launch-resolution' -import { claudeRecord } from './journal/claude-structured-item-translation' -import { startupAcquisitionRefusal } from '../../native-chat/agent-session-wire/structured-agent-session-failure-text' -import { providerDiagnosticOf } from '../../../shared/agent-session/failures/agent-session-failure' -import { claudeStreamJsonExitError } from '../claude-stream-json-exit-error' const owned: { claude: ReturnType @@ -33,9 +26,6 @@ const fixture = (name: string): Record[] => .split('\n') .map((line) => JSON.parse(line)) const MISSING = fixture('claude-missing-conversation')[0]! -const AUTH_FAILED = fixture('claude-lifecycle-capture-auth-failed') - .map((line) => claudeRecord(line.frame)) - .find((frame) => frame?.type === 'result')! const errorRows = (appendItem: ReturnType) => appendItem.mock.calls.filter(([, body]) => body.kind === 'status' && body.tone === 'error') @@ -73,265 +63,9 @@ describe('Claude host-awaited open', () => { } }) - // Recorded results; the send orderings after open are constructed, receipt ids omitted where named. - it.each([ - ['missing conversation after open, before any send', 1], - ['send before its echo', 1], - ['send idle left in doubt', 1], - ['ambiguous write', 1], - ['/goal answered without ids', 1], - ['started /goal answered without ids', 1] - ] as const)('reports a failure result (%s) in %i rows', async (path, rows) => { - const claude = fakeClaude({ replayUuid: null }) - const { adapter } = adapterFor(claude) - const appendItem = vi.fn() - await adapter.acquire({ ...ACQUIRE, events: { ...recordingJournalSink(), appendItem } }) - const connection = claude.connections[0]! - let result = MISSING - await claudeOpened(adapter, 'session-1') - if (!path.startsWith('missing conversation')) { - result = AUTH_FAILED - if (path === 'ambiguous write') { - const send = connection.send - connection.send = async (...args) => { - await send(...args) - throw new Error('input pump stopped') - } - } - const goal = path.includes('/goal') - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'unechoed', - body: goal - ? { ...USER_MESSAGE, blocks: [{ type: 'text', text: '/goal task' }] } - : USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: path === 'ambiguous write' ? 'unknown' : 'admitted' }) - const sentUuid = connection.sent[0]!.uuid - if (path === 'send idle left in doubt' || path === 'started /goal answered without ids') { - claudeFrame(connection, { - type: 'command_lifecycle', - command_uuid: sentUuid, - state: 'started' - }) - } - if (path === 'send idle left in doubt') { - claudeFrame(connection, { - type: 'system', - subtype: 'session_state_changed', - state: 'idle', - uuid: 'idle-1' - }) - } - // No user or assistant echo first; older CLIs omit the receipt ids. - result = goal - ? { ...result, user_message_uuid: undefined, user_message_uuids: undefined } - : { ...result, user_message_uuid: sentUuid, user_message_uuids: [sentUuid] } - } - claudeFrame(connection, { ...result, session_id: PROVIDER_SESSION_ID }) - expect(errorRows(appendItem)).toHaveLength(rows) - }) - - // Recorded results; the orderings are constructed. A resumed turn's result may arrive without an echo. - const RESUMED = { ...AUTH_FAILED, num_turns: 0, user_message_uuid: '', user_message_uuids: [] } - const evidence = [ - ['a receipt', { ...RESUMED, user_message_uuid: 'resumed-user' }], - ['a receipt list', { ...RESUMED, user_message_uuids: ['resumed-user'] }], - ['a resume reason', { ...RESUMED, resume_reason: 'host_draining' }], - ['model turns', { ...RESUMED, num_turns: 1 }], - ['no input', MISSING] - ] as const - it.each( - (['initialize', 'catch-up'] as const).flatMap((phase) => - evidence.map(([named, result]) => [named, phase, result] as const) - ) - )('a startup failure result naming %s during %s', async (_named, phase, result) => { - const claude = fakeClaude({ holdInit: phase === 'initialize', initProof: 'none' }) - const { adapter } = adapterFor(claude, { - resumesTranscript: true, - ...(phase === 'catch-up' ? { resumeLeafUuid: 'saved-leaf' } : {}) - }) - const appendItem = vi.fn() - const catchingUp = Promise.withResolvers() - const release = Promise.withResolvers<{ ok: true }>() - const written = () => { - catchingUp.resolve() - return release.promise - } - await adapter.acquire({ - ...ACQUIRE, - events: { - ...recordingJournalSink(), - appendItem, - ...(phase === 'catch-up' ? { written } : {}) - } - }) - const connection = claude.connections[0]! - const open = claudeOpened(adapter, 'session-1') - if (phase === 'catch-up') { - await catchingUp.promise - } - claudeFrame(connection, { ...result, session_id: PROVIDER_SESSION_ID }) - connection.initialization.resolve({}) - if (result !== MISSING) { - // A turn's own failure keeps its row; it does not fail the open. - release.resolve({ ok: true }) - await expect(open).resolves.toHaveProperty('link') - expect(errorRows(appendItem)).toHaveLength(1) - expect(connection.sent).toHaveLength(0) - return - } - // The open fails at once, catch-up still held, so its final startup row is the only report. - const error = await open.catch((failure: unknown) => failure) - release.resolve({ ok: true }) - expect(startupAcquisitionRefusal(error)?.reason).toBe('savedConversationMissing') - expect(providerDiagnosticOf(error)).toEqual({ - text: 'No conversation found with session ID: 00000000-0000-4000-8000-00000000dead', - audience: 'log' - }) - expect(errorRows(appendItem)).toHaveLength(0) - }) - // Recorded result; the orderings are constructed. A start another failure ended reports that cause. - it.each([ - ['initialize rejected', 1], - ['an exit with no stderr', 0], - ['an exit naming the missing conversation', 0], - ['the same result', 0] - ] as const)('a startup failure result after %s keeps %i rows', async (ended, rows) => { - const claude = fakeClaude({ holdInit: true, initProof: 'none' }) - const { adapter } = adapterFor(claude) - const appendItem = vi.fn() - await adapter.acquire({ ...ACQUIRE, events: { ...recordingJournalSink(), appendItem } }) - const connection = claude.connections[0]! - const open = claudeOpened(adapter, 'session-1').catch((failure: unknown) => failure) - if (ended === 'initialize rejected') { - connection.initialization.reject(new Error('initialize rejected')) - expect(await open).toHaveProperty('message', 'initialize rejected') - } else if (ended === 'the same result') { - claudeFrame(connection, { ...MISSING, session_id: PROVIDER_SESSION_ID }) - } else { - connection.closed = true - connection.exitVerdict = { root: 'exited', tree: 'exited' } - const stderr = ended === 'an exit with no stderr' ? '' : String(MISSING.errors) - connection.handlers.onExit?.( - claudeStreamJsonExitError(stderr, { code: 1, signal: null, processless: false }) - ) - } - // An exit naming no cause leaves the start to the buffered result, so that result is its one failure. - claudeFrame(connection, { ...MISSING, session_id: PROVIDER_SESSION_ID }) - const error = await open - if (ended === 'an exit with no stderr') { - expect(startupAcquisitionRefusal(error)?.reason).toBe('savedConversationMissing') - } - expect(errorRows(appendItem)).toHaveLength(rows) - }) // Whichever ends the start first is its report: a hidden result is the open's error, else its row stays. - it.each(Array.from({ length: 17 }, (_, gap) => gap))( - 'a startup failure result %i microtasks after initialize rejects is reported once', - async (gap) => { - const claude = fakeClaude({ holdInit: true, initProof: 'none' }) - const { adapter } = adapterFor(claude) - const appendItem = vi.fn() - await adapter.acquire({ ...ACQUIRE, events: { ...recordingJournalSink(), appendItem } }) - const connection = claude.connections[0]! - const open = claudeOpened(adapter, 'session-1').catch((failure: unknown) => failure) - connection.initialization.reject(new Error('initialize rejected')) - for (let tick = 0; tick < gap; tick++) { - await Promise.resolve() - } - claudeFrame(connection, { ...MISSING, session_id: PROVIDER_SESSION_ID }) - const error = await open - const typed = startupAcquisitionRefusal(error)?.reason === 'savedConversationMissing' - expect(errorRows(appendItem)).toHaveLength(typed ? 0 : 1) - expect(typed || (error instanceof Error && error.message === 'initialize rejected')).toBe( - true - ) - } - ) - - it.each([ - ['naming the missing conversation', 0], - ['with no stderr', 1] - ] as const)( - 'a transport fault %s during catch-up fails the open and keeps %i rows', - async (named, rows) => { - const claude = fakeClaude({ initProof: 'none' }) - const { adapter } = adapterFor(claude, { - resumesTranscript: true, - resumeLeafUuid: 'saved-leaf' - }) - const appendItem = vi.fn() - const catchingUp = Promise.withResolvers() - const release = Promise.withResolvers<{ ok: true }>() - const written = () => { - catchingUp.resolve() - return release.promise - } - await adapter.acquire({ - ...ACQUIRE, - events: { ...recordingJournalSink(), appendItem, written } - }) - const open = claudeOpened(adapter, 'session-1').catch((failure: unknown) => failure) - await catchingUp.promise - const connection = claude.connections[0]! - connection.closed = true - const stderr = named === 'with no stderr' ? '' : String(MISSING.errors) - const fault = claudeStreamJsonExitError(stderr, { - code: null, - signal: null, - processless: false - }) - connection.handlers.onFault?.(fault) - claudeFrame(connection, { ...MISSING, session_id: PROVIDER_SESSION_ID }) - release.resolve({ ok: true }) - expect(await open).toMatchObject({ cause: fault }) - expect(errorRows(appendItem)).toHaveLength(rows) - } - ) - - it('fails the open with a startup failure Claude reported before publication', async () => { - const claude = fakeClaude({ initProof: 'none' }) - const openConnection: typeof claude.openConnection = async (...args) => { - const connection = await claude.openConnection(...args) - claudeFrame(claude.connections.at(-1)!, { ...MISSING, session_id: PROVIDER_SESSION_ID }) - return connection - } - const { adapter } = adapterFor({ ...claude, openConnection }) - const appendItem = vi.fn() - await adapter.acquire({ ...ACQUIRE, events: { ...recordingJournalSink(), appendItem } }) - const error = await claudeOpened(adapter, 'session-1').catch((failure: unknown) => failure) - expect(startupAcquisitionRefusal(error)?.reason).toBe('savedConversationMissing') - expect(errorRows(appendItem)).toHaveLength(0) - // A child's failure is never the start's. - claudeFrame(claude.connections[0]!, { - ...MISSING, - session_id: PROVIDER_SESSION_ID, - parent_tool_use_id: 'child-1' - }) - expect(errorRows(appendItem)).toHaveLength(1) - }) - - it('lets a late frame fail neither a ready acquisition nor its replacement', async () => { - const claude = fakeClaude({ initProof: 'none' }) - const { adapter } = adapterFor(claude) - const appendItem = vi.fn() - const events = { ...recordingJournalSink(), appendItem } - await adapter.acquire({ ...ACQUIRE, events }) - await claudeOpened(adapter, 'session-1') - const ready = claude.connections[0]! - claudeFrame(ready, { ...MISSING, session_id: PROVIDER_SESSION_ID }) - expect(errorRows(appendItem)).toHaveLength(1) - await adapter.closeSession('session-1') - await adapter.acquire({ ...ACQUIRE, events }) - claudeFrame(ready, { ...MISSING, session_id: PROVIDER_SESSION_ID }) - await expect(claudeOpened(adapter, 'session-1')).resolves.toHaveProperty('link') - claudeFrame(claude.connections[1]!, { ...MISSING, session_id: PROVIDER_SESSION_ID }) - expect(errorRows(appendItem)).toHaveLength(2) - }) it('leaves an open Stop aborted to Stop when Claude then reports a startup failure', async () => { const claude = fakeClaude({ holdInit: true, initProof: 'none' }) @@ -355,115 +89,6 @@ describe('Claude host-awaited open', () => { }) // SDK-typed shape; ordering constructed - it.each([ - ['bypassPermissions', 'full-access'], - ['plan', 'plan'], - [null, null], - [undefined, 'accept-edits'] - ] as const)( - 'preserves the startup status permission report %s over init', - async (mode, expected) => { - const claude = fakeClaude() - const adapter = adapterAtPublishFor({ - ...claude, - openConnection: async (...args) => { - const connection = await claude.openConnection(...args) - const observed = claude.connections[0] - if (!observed) { - throw new Error('missing test connection') - } - claudeFrame(observed, { - type: 'system', - subtype: 'init', - permissionMode: 'acceptEdits', - model: 'claude-sonnet-5' - }) - claudeFrame(observed, { - type: 'system', - subtype: 'status', - status: null, - ...(mode === undefined ? {} : { permissionMode: mode }) - }) - return connection - } - }) - await adapter.acquire({ ...ACQUIRE, options: { permissionMode: 'own' } }) - const opened = await claudeOpened(adapter, 'session-1') - expect(opened.reportedOptions?.reportedPermissionMode).toBe(expected) - expect( - (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current - .reportedPermissionMode - ).toBe(expected) - const connection = claude.connections[0] - if (!connection) { - throw new Error('missing test connection') - } - expect(connection.launch.options.permissionMode).toBeUndefined() - expect(connection.calls.some((call) => call.subtype === 'set_permission_mode')).toBe(false) - await adapter.closeAll() - } - ) - - it('refreshes only present owned permission reports through the existing options signal', async () => { - const claude = fakeClaude() - const { adapter, events } = adapterFor(claude) - await adapter.acquire({ ...ACQUIRE, options: { permissionMode: 'own' } }) - await claudeOpened(adapter, 'session-1') - const connection = claude.connections[0] - if (!connection) { - throw new Error('missing test connection') - } - for (const [frame, expected, changed] of [ - [ - { subtype: 'init', permissionMode: 'acceptEdits', model: 'claude-haiku-4-5' }, - 'accept-edits', - true - ], - [{ subtype: 'status', status: null, permissionMode: 'default' }, 'ask', true], - [{ subtype: 'status', status: 'compacting' }, 'ask', false], - [{ subtype: 'init', model: 'claude-haiku-4-5' }, 'ask', false], - [{ subtype: 'status', status: null, permissionMode: 'plan' }, 'plan', true], - [{ subtype: 'status', status: null, permissionMode: null }, null, true], - [ - { subtype: 'status', permissionMode: 'bypassPermissions', session_id: 'foreign' }, - null, - false - ] - ] as const) { - const before = events.filter((event) => event.type === 'options-reported').length - claudeFrame(connection, { type: 'system', ...frame }) - expect( - (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current - .reportedPermissionMode - ).toBe(expected) - expect(events.filter((event) => event.type === 'options-reported').length - before).toBe( - changed ? 1 : 0 - ) - } - expect(connection.calls.some((call) => call.subtype === 'set_permission_mode')).toBe(false) - await adapter.closeAll() - }) - - it('publishes the owned process as starting before initialize answers', async () => { - const claude = fakeClaude({ initDelayMs: 12_000 }) - const { adapter, events } = adapterFor(claude) - const acquisition = await adapter.acquire(ACQUIRE) - expect(acquisition).toMatchObject({ process: { pid: 4321 } }) - expect(acquisition.link).toBeUndefined() - expect(claude.connections[0].calls).toEqual([]) - expect(events.some((event) => event.type === 'options')).toBe(false) - - const open = claudeOpened(adapter, 'session-1') - await vi.advanceTimersByTimeAsync(11_999) - expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual(['initialize']) - await vi.advanceTimersByTimeAsync(1) - await expect(open).resolves.toMatchObject({ - link: expect.objectContaining({ handle: expect.objectContaining({ agent: 'claude' }) }), - reportedOptions: expect.any(Object) - }) - expect(events.map((event) => event.type)).not.toContain('started') - await adapter.closeAll() - }) it('returns from open without waiting for optional settings', async () => { let answerSettings: (value: unknown) => void = () => {} @@ -490,82 +115,6 @@ describe('Claude host-awaited open', () => { } }) - it('applies a Fast choice at message input when launch settings cannot carry it', async () => { - let fastMode = false - const claude = fakeClaude({ - initModels: [{ value: 'claude-sonnet-5', displayName: 'Sonnet', supportsFastMode: true }], - routes: { - apply_flag_settings: () => { - fastMode = true - } - } - }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterAtPublishFor( - claude, - { options: { extraArgs: { settings: '/repo/claude.json' } } }, - events - ) - await adapter.acquire({ ...ACQUIRE, options: { fastMode: 'true' } }) - await claudeOpened(adapter, 'session-1') - await vi.waitFor(() => - expect(events.some((event) => event.type === 'options-reported')).toBe(true) - ) - const connection = claude.connections[0]! - expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([]) - connection.getSettings = async () => ({ effective: { effortLevel: 'high', fastMode } }) - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'fast-choice', - body: USER_MESSAGE, - executionOptions: { fastMode: 'true' }, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'admitted' }) - expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([ - { subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } } - ]) - expect(connection.sent).toHaveLength(1) - await adapter.closeAll() - }) - - it('rejects an advertised signed-out initialize answer for the host to close once', async () => { - const claude = fakeClaude({ initAccount: { tokenSource: 'none', apiKeySource: 'none' } }) - const { adapter } = adapterFor(claude) - await adapter.acquire(ACQUIRE) - await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('not signed in') - expect(claude.connections[0].closeCount).toBe(0) - await adapter.closeSession('session-1') - expect(claude.connections[0].closeCount).toBe(1) - }) - - it('rejects a frame that proves a different provider session', async () => { - const claude = fakeClaude({ initSessionId: 'foreign-session' }) - const { adapter } = adapterFor(claude) - await adapter.acquire(ACQUIRE) - await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('provider session expected') - await adapter.closeSession('session-1') - expect(claude.connections[0].closeCount).toBe(1) - }) - - it('fails open when initialize does not answer within its own bound', async () => { - const claude = fakeClaude({ initDelayMs: 10 * 60_000 }) - const { adapter } = adapterFor(claude) - await adapter.acquire(ACQUIRE) - const open = claudeOpened(adapter, 'session-1').catch((error: unknown) => error) - await vi.advanceTimersByTimeAsync(CLAUDE_INITIALIZE_TIMEOUT_MS) - const error = await open - expect(error).toBeInstanceOf(Error) - expect(String(error)).toContain('did not answer initialize') - expect(providerRequestTimeoutOf(error)).toEqual({ - method: 'initialize', - timeoutMs: CLAUDE_INITIALIZE_TIMEOUT_MS - }) - await adapter.closeSession('session-1') - expect(claude.connections[0].closeCount).toBe(1) - }) - it('lets the host abort a silent initialize wait and close the owned process', async () => { const claude = fakeClaude({ initDelayMs: 12_000 }) const { adapter } = adapterFor(claude) diff --git a/src/main/claude/structured/claude-structured-sign-in.test.ts b/src/main/claude/structured/claude-structured-sign-in.test.ts deleted file mode 100644 index 1e90810d2b6..00000000000 --- a/src/main/claude/structured/claude-structured-sign-in.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { describe, expect, it } from 'vitest' -import { z } from 'zod' -import type { AgentJournalItemBody } from '../../../shared/agent-session/journal/agent-session-journal-types' -import { ClaudeAssistantFailures } from '../claude-assistant-failures' -import { createClaudeJournalTranslator } from './journal/claude-structured-journal-translation' - -const entry = z.looseObject({ kind: z.string() }) -const capturedFrame = z.object({ at: z.number(), frame: z.record(z.string(), z.unknown()) }) - -async function captureFrames() { - const capture = await readFile( - new URL('../__fixtures__/claude-lifecycle-capture-auth-failed.jsonl', import.meta.url), - 'utf8' - ) - return capture - .trim() - .split('\n') - .flatMap((line) => { - const value: unknown = JSON.parse(line) - return entry.parse(value).kind === 'frame' ? [capturedFrame.parse(value)] : [] - }) -} - -async function replay(account: 'system' | 'managed', removeTypedError = false) { - const bodies: AgentJournalItemBody[] = [] - const translator = createClaudeJournalTranslator({ - account: () => account, - sink: { - appendItem: (_identity, body) => bodies.push(body), - appendTombstone: () => {}, - publish: () => {} - } - }) - for (const { at, frame } of await captureFrames()) { - if (removeTypedError) { - delete frame.error - } - translator.handle({ type: 'message', sessionId: 'session', observedAt: at, message: frame }) - } - translator.dispose() - return bodies -} - -describe('captured Claude authentication failure', () => { - it.each(['system', 'managed'] as const)( - 'shows one failure with %s sign-in advice and Claude detail', - async (account) => { - const bodies = await replay(account) - const failures = bodies.flatMap((body) => - body.kind === 'status' && body.failure ? [body] : [] - ) - expect(failures).toHaveLength(1) - expect(failures[0]?.failure).toMatchObject({ - kind: 'notSignedIn', - account, - detail: { text: 'Not logged in · Please run /login', audience: 'person' } - }) - expect(failures[0]?.text).toContain( - account === 'managed' ? 'Sign in again in Claude Accounts settings.' : 'claude auth login' - ) - expect( - bodies.filter((body) => body.kind === 'message' && body.role === 'assistant') - ).toHaveLength(0) - expect( - bodies.flatMap((body) => - body.kind === 'status' && body.text.includes('Not logged in') ? [body] : [] - ) - ).toHaveLength(1) - } - ) - it('does not infer authentication from result text without the typed assistant error', async () => { - expect( - (await replay('system', true)).some( - (body) => body.kind === 'status' && body.failure?.kind === 'notSignedIn' - ) - ).toBe(false) - }) - - it.each([ - ['u1', 'u1'], - [undefined, 'u1'], - ['u1', undefined], - [undefined, undefined] - ])( - 'associates optional send identities %s / %s only until settlement', - async (assistantId, resultId) => { - const frames = (await captureFrames()).map(({ frame }) => frame) - for (const error of ['authentication_failed', 'model_not_found']) { - const bodies: AgentJournalItemBody[] = [] - const translator = createClaudeJournalTranslator({ - model: () => 'opus[1m]', - sink: { - appendItem: (_identity, body) => bodies.push(body), - appendTombstone: () => {}, - publish: () => {} - } - }) - for (const frame of frames) { - translator.handle({ - type: 'message', - sessionId: 'session', - message: { - ...frame, - ...(frame.type === 'assistant' ? { error, user_message_uuid: assistantId } : {}), - ...(frame.type === 'result' - ? { - user_message_uuid: resultId, - user_message_uuids: resultId ? [resultId] : undefined - } - : {}) - } - }) - } - expect( - bodies.filter((body) => body.kind === 'status' && body.tone === 'error') - ).toHaveLength(1) - translator.handle({ - type: 'message', - sessionId: 'session', - message: { - ...frames.find((frame) => frame.type === 'result'), - uuid: 'unrelated-result', - user_message_uuid: 'u2', - user_message_uuids: ['u2'], - result: 'API Error: 529 upstream overloaded' - } - }) - expect(bodies.at(-1)).toMatchObject({ - kind: 'status', - text: 'API Error: 529 upstream overloaded' - }) - translator.dispose() - } - } - ) - - it.each(['different send', 'different send list', 'next user', 'successful result'])( - 'does not let a pending failure hide a later error after %s', - async (boundary) => { - const frames = (await captureFrames()).map(({ frame }) => frame) - const bodies: AgentJournalItemBody[] = [] - const translator = createClaudeJournalTranslator({ - sink: { - appendItem: (_identity, body) => bodies.push(body), - appendTombstone: () => {}, - publish: () => {} - } - }) - const handle = (message: Record) => - translator.handle({ type: 'message', sessionId: 'session', message }) - const assistant = frames.find((frame) => frame.type === 'assistant')! - const result = frames.find((frame) => frame.type === 'result')! - handle({ ...assistant, error: 'model_not_found', user_message_uuid: 'u1' }) - if (boundary === 'next user') { - handle({ - type: 'user', - uuid: 'u2', - session_id: assistant.session_id, - message: { role: 'user', content: [{ type: 'text', text: 'try again' }] } - }) - } - handle({ - ...result, - user_message_uuid: boundary === 'different send' ? 'u2' : undefined, - user_message_uuids: boundary === 'different send list' ? ['u2'] : undefined, - is_error: boundary !== 'successful result', - terminal_reason: boundary === 'successful result' ? 'completed' : 'api_error', - result: boundary === 'successful result' ? 'ok' : 'API Error: 529 upstream overloaded' - }) - if (boundary !== 'successful result') { - expect(bodies.at(-1)).toMatchObject({ - kind: 'status', - text: 'API Error: 529 upstream overloaded' - }) - } - handle({ - ...result, - uuid: 'later-result', - user_message_uuid: 'u1', - user_message_uuids: ['u1'], - result: 'API Error: 500 later failure' - }) - expect(bodies.at(-1)).toMatchObject({ kind: 'status', text: 'API Error: 500 later failure' }) - translator.dispose() - } - ) -}) - -it('bounds pending result UUID association to two list passes and releases it on settlement', () => { - for (const size of [64, 640]) { - const counts = { validation: 0, membership: 0 } - const numeric = (key: string | symbol) => typeof key === 'string' && /^\d+$/.test(key) - const uuids = new Proxy( - Array.from({ length: size }, (_, i) => (i === size - 1 ? 'u1' : `other-${i}`)), - { - get(array, key, receiver) { - if (numeric(key)) { - counts.validation += 1 - return array[Number(key)] - } - if (key === 'filter') { - return (callback: (value: string, index: number, array: string[]) => unknown) => - new Proxy(array.filter.call(receiver, callback), { - get(filtered, property) { - if (numeric(property)) { - counts.membership += 1 - return filtered[Number(property)] - } - if (property === 'length' || property === 'includes') { - return filtered[property] - } - throw new Error(`Unexpected membership-list property: ${String(property)}`) - } - }) - } - if (key === 'length' || key === 'constructor') { - return array[key] - } - throw new Error(`Unexpected validation-list property: ${String(key)}`) - } - } - ) - for (const outcome of ['match', 'mismatch', 'success']) { - const failures = new ClaudeAssistantFailures() - const result = { session_id: 's', is_error: outcome !== 'success', user_message_uuids: uuids } - counts.validation = counts.membership = 0 - expect(failures.resultAlreadyShown(result)).toBe(false) - expect(counts).toEqual({ validation: 0, membership: 0 }) - failures.assistant({ - type: 'assistant', - session_id: 's', - error: 'model_not_found', - user_message_uuid: outcome === 'mismatch' ? 'absent' : 'u1', - message: { content: [] } - }) - expect(failures.resultAlreadyShown(result)).toBe(outcome === 'match') - expect(counts.validation).toBeGreaterThanOrEqual(size) - expect(counts.membership).toBeGreaterThanOrEqual(outcome === 'success' ? 0 : size) - expect(counts.validation + counts.membership).toBeLessThanOrEqual(2 * size + 1) - counts.validation = counts.membership = 0 - expect(failures.resultAlreadyShown({ ...result, is_error: true })).toBe(false) - expect(counts).toEqual({ validation: 0, membership: 0 }) - } - } -}) diff --git a/src/main/claude/structured/claude-structured-turn-resume-point.test.ts b/src/main/claude/structured/claude-structured-turn-resume-point.test.ts index 17eac340956..893240b8d72 100644 --- a/src/main/claude/structured/claude-structured-turn-resume-point.test.ts +++ b/src/main/claude/structured/claude-structured-turn-resume-point.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { createTrackedJournalOpener } from '../../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../../native-chat/agent-session-wire/structured-agent-session-event-sink' import { testEventSinkLogging } from '../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { createClaudeStructuredLaunchResolver } from '../../runtime/agent-session/launch/structured-agent-launch-composition.test-support' import { afterEach, describe, expect, it, vi } from 'vitest' import { agentSessionLeaseFixture, @@ -12,7 +11,6 @@ import { } from '../../../shared/agent-session/lifecycle/agent-session-record.test-fixture' import type { AgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { AgentSessionRecordStore } from '../../runtime/agent-session/records/agent-session-record-store' import { reviseAgentSessionProviderResumePoint } from '../../runtime/agent-session/ownership/agent-session-provider-handle-transition' import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import type { @@ -175,18 +173,6 @@ afterEach(async () => { }) describe('Claude durable resume point at turn end', () => { - it.each([false, true])( - 'allows a first native checkpoint without a loaded leaf (resume=%s)', - async (resumesTranscript) => { - const { store, turn } = await liveOwner(undefined, { - resumeLeafUuid: null, - resumesTranscript - }) - await turn('u1', 'a1') - expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'a1' }) - } - ) - it('re-derives missed suffix coverage on the next acquisition before advancing', async () => { const { adapter, store, turn, transcript, journal, deferred } = await liveOwner(undefined, { missingTranscript: true @@ -237,170 +223,4 @@ describe('Claude durable resume point at turn end', () => { await turn('u2', 'a2') expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'a2' }) }) - - it.each([ - { missingTranscript: true }, - { - beforeOpen: (frame: (message: Record) => void) => - frame({ - type: 'stream_event', - uuid: 'early', - event: { type: 'message_start', message: { id: 'early' } } - }) - } - ])('retains the loaded leaf after unproved history, including close: %j', async (options) => { - const { adapter, store, events, persistedHandles, turn } = await liveOwner(undefined, options) - await turn('u1', 'a1') - expect( - events.some((event) => event.type === 'message' && event.message.type === 'result') - ).toBe(true) - expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ - resumeCursor: 'resumed-at' - }) - await adapter.closeAll() - expect(persistedHandles).toEqual([expect.objectContaining({ leafUuid: 'resumed-at' })]) - }) - - it('publishes the result while journal coverage is pending, and retains the leaf on failure', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { adapter, store, turn, deferred, events, persistedHandles } = await liveOwner() - const write = Promise.withResolvers<{ ok: false; error: Error }>() - deferred.sink.written = () => write.promise - await turn('u1', 'a1') - expect( - events.some((event) => event.type === 'message' && event.message.type === 'result') - ).toBe(true) - expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ - resumeCursor: 'resumed-at' - }) - write.resolve({ ok: false, error: new Error('journal failed') }) - await tick() - await adapter.closeAll() - expect(persistedHandles).toEqual([expect.objectContaining({ leafUuid: 'resumed-at' })]) - }) - - it('advances after each completed turn without growing the handle chain', async () => { - const { adapter, store, turn } = await liveOwner() - try { - await turn('u1', 'a1') - expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'a1' }) - await turn('u2', 'a2') - expect(store.record.providerHandleChain).toHaveLength(2) - expect(store.record.providerHandleChain.at(-1)).toMatchObject({ - linkId: 'published-link', - handle: { resumeCursor: 'a2' } - }) - } finally { - await adapter.closeAll() - } - }) - - it('keeps the last completed turn after a crash that ran no close or exit', async () => { - const { store, frame, turn } = await liveOwner() - await turn('u1', 'a1') - await turn('u2', 'a2') - frame({ type: 'user', uuid: 'u3' }) - await tick() - const head = store.record.providerHandleChain.at(-1)!.handle - expect(head).toMatchObject({ resumeCursor: 'a2' }) - - const resolve = createClaudeStructuredLaunchResolver({ - resolveLaunchArgs: () => [], - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only getRecord from its store. - store: { getRecord: () => store.record } as unknown as AgentSessionRecordStore, - resolveWorkspacePath: async (id) => `/repos/${id}`, - resolveCommand: () => ({ command: '/usr/local/bin/claude', prefixArgs: [] }), - resolveAuthPolicy: () => ({ account: 'system' }) - }) - const launch = await resolve({ - identity: { - ...identityFor(store.record.sessionId), - providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'a2') - } - }) - // Resume the conversation without rewinding to the bookkeeping cursor. - expect(launch).toMatchObject({ resumeLeafUuid: 'a2', options: { resume: PROVIDER_SESSION_ID } }) - expect(launch.options).not.toHaveProperty('resumeSessionAt') - }) - - it('lands the close cursor after, never under, an in-flight turn-end write', async () => { - const write = Promise.withResolvers() - const { adapter, persistedHandles, turn } = await liveOwner(() => write.promise) - await turn('u1', 'a1') - const closed = adapter.closeSession('session-1') - await tick() - expect(persistedHandles).toEqual([]) - write.resolve() - await expect(closed).resolves.toBe(true) - expect(persistedHandles).toEqual([expect.objectContaining({ leafUuid: 'a1' })]) - }) - - it('never fails or holds up a turn when the write fails', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const persist = vi.fn(async () => { - throw new Error('record write failed') - }) - const { adapter, events, persistedHandles, turn } = await liveOwner(persist) - await turn('u1', 'a1') - await turn('u2', 'a2') - expect(persist).toHaveBeenCalledTimes(2) - expect( - events.filter((event) => event.type === 'message' && event.message.type === 'result') - ).toHaveLength(2) - expect(warn).toHaveBeenCalledWith( - '[claude-resume-point] turn-end resume point was not persisted:', - expect.objectContaining({ leafUuid: 'a2' }) - ) - await expect(adapter.closeSession('session-1')).resolves.toBe(true) - expect(persistedHandles).toEqual([expect.objectContaining({ leafUuid: 'a2' })]) - }) - - it('retries a failed write at the next turn end even when the leaf has not moved', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - let failing = true - const persist = vi.fn(async () => { - if (failing) { - throw new Error('record write failed') - } - }) - const { adapter, frame, turn } = await liveOwner(persist) - try { - await turn('u1', 'a1') - expect(persist).toHaveBeenCalledTimes(1) - failing = false - frame({ type: 'result', subtype: 'error_during_execution', uuid: 'a1-result-2' }) - await tick() - expect(persist).toHaveBeenCalledTimes(2) - expect(persist).toHaveBeenLastCalledWith(expect.objectContaining({ leafUuid: 'a1' })) - } finally { - await adapter.closeAll() - } - }) - - it('keeps completed output drained through the same connection after its root exits', async () => { - const persist = vi.fn(async () => {}) - const { adapter, claude, frame, persistedHandles, turn } = await liveOwner(persist) - await turn('u1', 'a1') - expect(persist).toHaveBeenCalledTimes(1) - const connection = claude.connections[0]! - const close = connection.close.bind(connection) - const drained = Promise.withResolvers() - connection.close = async () => { - await drained.promise - return close() - } - connection.handlers.onExit?.(new Error('claude crashed')) - frame({ type: 'user', uuid: 'u2', message: { role: 'user', content: 'buffered ask' } }) - frame({ - type: 'assistant', - uuid: 'a2', - message: { role: 'assistant', content: [{ type: 'text', text: 'buffered answer' }] } - }) - frame({ type: 'result', subtype: 'success', uuid: 'a2-result' }) - drained.resolve() - await adapter.drainObservedExits() - await tick() - expect(persist).toHaveBeenCalledTimes(1) - expect(persistedHandles).toEqual([expect.objectContaining({ leafUuid: 'a2' })]) - }) }) diff --git a/src/main/claude/structured/journal/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/structured/journal/claude-structured-journal-translation-background-tasks.test.ts deleted file mode 100644 index ac29ecc07a4..00000000000 --- a/src/main/claude/structured/journal/claude-structured-journal-translation-background-tasks.test.ts +++ /dev/null @@ -1,754 +0,0 @@ -import type { AgentJournalItemBody } from '../../../../shared/agent-session/journal/agent-session-journal-types' -import { describe, expect, it, vi } from 'vitest' -import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key' -import type { AgentJournalItemIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity' -import { - createDeferredStructuredAgentSessionEventSink, - type StructuredAgentSessionEventSink, - type StructuredAgentSessionEventTarget -} from '../../../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { - StructuredAgentSessionLifecycleJournal, - StructuredAgentSessionLinkageJournal -} from '../../../native-chat/agent-session-wire/structured-agent-session-journal-views' -import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store' -import { createClaudeJournalTranslator } from './claude-structured-journal-translation' -import { blockOf } from '../../claude-background-task-row-test-support' -import { withJournalQueueMembers } from '../../../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support' -import { testEventSinkLogging } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' - -// Captured failures formerly produced duplicate rows displaying wire opcodes. -const TASK_ID = 'byjnee2no' -const SUMMARY = 'Background command "Wait for the verification verdict" failed with exit code 1' - -function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null { - return identity.provider === 'orca' ? identity.clientMessageId : null -} - -function harness() { - const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] - const sink: StructuredAgentSessionEventSink = { - appendItem: (identity, body) => items.push({ identity, body }), - appendTombstone: vi.fn(), - publish: vi.fn() - } - const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) - const rowsWithPrefix = (prefix: string): AgentJournalItemBody[] => - items - .filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith(prefix)) - .map((item) => item.body) - const textOf = (body: AgentJournalItemBody): string => { - if (body.kind === 'status') { - return body.text - } - return body.kind === 'message' - ? body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join(' ') - : '' - } - return { - translator, - items, - fallbackRows: () => rowsWithPrefix('provider-frame:').map(textOf), - taskRowIds: () => - items - .map((item) => orcaClientMessageId(item.identity) ?? '') - .filter((id) => id.startsWith('claude-background-task:')), - taskRowTexts: () => rowsWithPrefix('claude-background-task:').map(textOf) - } -} - -function systemFrame(fields: Record) { - return { - type: 'message' as const, - sessionId: 'orca-session', - message: { type: 'system', session_id: 'claude-session', ...fields } - } -} - -/** Forward root tool calls before task frames so admission can distinguish nested work. */ -function spawnToolCall( - translator: ReturnType['translator'], - toolUseId = 'toolu_01CqPd7y' -): void { - translator.handle({ - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'assistant', - uuid: `assistant-${toolUseId}`, - session_id: 'claude-session', - parent_tool_use_id: null, - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'wait' } }] - } - } - }) -} - -function playFailedBackgroundCommand(translator: ReturnType['translator']): void { - spawnToolCall(translator) - translator.handle( - systemFrame({ - subtype: 'task_started', - task_id: TASK_ID, - tool_use_id: 'toolu_01CqPd7y', - task_type: 'local_bash', - description: 'Wait for the verification verdict', - is_backgrounded: true - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_updated', - task_id: TASK_ID, - patch: { status: 'failed', end_time: 1_789_332_035_695 } - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_notification', - task_id: TASK_ID, - tool_use_id: 'toolu_01CqPd7y', - status: 'failed', - output_file: '/private/tmp/claude-501/tasks/byjnee2no.output', - summary: SUMMARY - }) - ) -} - -function fillLiveTaskRows(translator: ReturnType['translator']): void { - for (let index = 0; index < 64; index += 1) { - const toolUseId = `toolu-live-${index}` - spawnToolCall(translator, toolUseId) - translator.handle( - systemFrame({ - subtype: 'task_started', - task_id: `live-${index}`, - tool_use_id: toolUseId, - task_type: 'local_bash', - description: `live ${index}`, - is_backgrounded: true - }) - ) - } -} - -function persistedTarget( - persisted: Map -): StructuredAgentSessionEventTarget { - const journal = - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test double implements the journal methods exercised by the deferred sink. - withJournalQueueMembers({ - appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - persisted.set(agentJournalItemKey(identity), body) - return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 } - }, - appendTombstone: vi.fn(), - visitItems: ( - visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void - ) => { - for (const [itemId, body] of persisted) { - visit(itemId, 0, body) - } - }, - // No producer linkage: all fixture rows belong to the session. - visitItemsWithLinkage: ((visit) => { - persisted.forEach((body, itemId) => visit(itemId, 0, body, {})) - }) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'], - itemBody: (itemId: string) => persisted.get(itemId) ?? null, - threadGoalSource: () => ({ goal: null, row: null }), - epoch: 'test' - }) as unknown as AgentSessionJournal - return { journal, fence: 1, publish: vi.fn() } -} - -describe('claude journal translation — background task rows', () => { - it('coalesces an unbound overflow patch and aliased final notification', async () => { - const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - const translator = createClaudeJournalTranslator({ - sink: deferred.sink, - fallbackIdPrefix: 'test' - }) - fillLiveTaskRows(translator) - translator.handle( - systemFrame({ subtype: 'task_started', task_id: 'queued-overflow', task_type: 'local_bash' }) - ) - translator.handle( - systemFrame({ - subtype: 'task_updated', - task_id: 'queued-overflow', - patch: { status: 'failed' } - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_notification', - task_id: 'queued-overflow', - tool_use_id: 'toolu-final', - status: 'stopped', - summary: 'No completion record was found' - }) - ) - deferred.bind(persistedTarget(persisted)) - await deferred.drained() - - expect([...persisted.keys()].filter((key) => key.includes('queued-overflow'))).toEqual([ - 'orca:claude-background-task%3Aqueued-overflow' - ]) - expect(blockOf(persisted.get('orca:claude-background-task%3Aqueued-overflow'))).toMatchObject({ - state: 'idle', - parentToolUseId: 'toolu-final', - summary: 'No completion record was found' - }) - }) - - it('reconciles an aliased notification after a parentless overflow patch was persisted', async () => { - const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind(persistedTarget(persisted)) - const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) - fillLiveTaskRows(first) - first.handle( - systemFrame({ subtype: 'task_started', task_id: 'bound-overflow', task_type: 'local_bash' }) - ) - first.handle( - systemFrame({ - subtype: 'task_updated', - task_id: 'bound-overflow', - patch: { status: 'failed' } - }) - ) - await deferred.drained() - - const resumed = createClaudeJournalTranslator({ - sink: deferred.sink, - fallbackIdPrefix: 'resumed' - }) - resumed.handle( - systemFrame({ - subtype: 'task_notification', - task_id: 'bound-overflow', - tool_use_id: 'toolu-first', - status: 'stopped', - summary: 'No completion record was found' - }) - ) - await deferred.drained() - expect([...persisted.keys()].filter((key) => key.includes('bound-overflow'))).toEqual([ - 'orca:claude-background-task%3Abound-overflow' - ]) - expect(blockOf(persisted.get('orca:claude-background-task%3Abound-overflow'))).toMatchObject({ - state: 'idle', - parentToolUseId: 'toolu-first' - }) - - const restarted = createClaudeJournalTranslator({ - sink: deferred.sink, - fallbackIdPrefix: 'next' - }) - spawnToolCall(restarted, 'toolu-second') - restarted.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'bound-overflow', - tool_use_id: 'toolu-second', - task_type: 'local_bash', - is_backgrounded: true - }) - ) - await deferred.drained() - expect([...persisted.keys()].filter((key) => key.includes('bound-overflow'))).toEqual([ - 'orca:claude-background-task%3Abound-overflow', - 'orca:claude-background-task%3Abound-overflow%232' - ]) - }) - - it('resolves a queued restart identity after the sink rebinds', async () => { - const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind(persistedTarget(persisted)) - - const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) - spawnToolCall(first, 'toolu-first') - first.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'queued-restart', - tool_use_id: 'toolu-first', - task_type: 'local_bash', - is_backgrounded: true - }) - ) - await deferred.drained() - first.dispose() - await deferred.drained() - - const restarted = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - const second = createClaudeJournalTranslator({ - sink: restarted.sink, - fallbackIdPrefix: 'second' - }) - spawnToolCall(second, 'toolu-second') - second.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'queued-restart', - tool_use_id: 'toolu-second', - task_type: 'local_bash', - is_backgrounded: true - }) - ) - restarted.bind(persistedTarget(persisted)) - await restarted.drained() - - expect([...persisted.keys()].filter((key) => key.includes('queued-restart'))).toEqual([ - 'orca:claude-background-task%3Aqueued-restart', - 'orca:claude-background-task%3Aqueued-restart%232' - ]) - }) - - it('keeps pending writes from distinct runs when a translator is recreated', async () => { - const persisted = new Map() - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - - const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' }) - spawnToolCall(first, 'toolu-first') - first.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'queued-overlap', - tool_use_id: 'toolu-first', - task_type: 'local_bash', - is_backgrounded: true - }) - ) - first.dispose() - - const second = createClaudeJournalTranslator({ - sink: deferred.sink, - fallbackIdPrefix: 'second' - }) - spawnToolCall(second, 'toolu-second') - second.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'queued-overlap', - tool_use_id: 'toolu-second', - task_type: 'local_bash', - is_backgrounded: true - }) - ) - - deferred.bind(persistedTarget(persisted)) - await deferred.drained() - - expect([...persisted.keys()].filter((key) => key.includes('queued-overlap'))).toEqual([ - 'orca:claude-background-task%3Aqueued-overlap', - 'orca:claude-background-task%3Aqueued-overlap%232' - ]) - }) - - it('does not overwrite a prior run when a new translator sees a reused task id', () => { - const persisted = new Map() - const journal: StructuredAgentSessionLifecycleJournal = { - epoch: '', - visitItems: (visit) => { - for (const [itemId, body] of persisted) { - visit(itemId, 0, body) - } - } - } - const sink: StructuredAgentSessionEventSink = { - appendItem: (identity, body) => persisted.set(agentJournalItemKey(identity), body), - appendTombstone: vi.fn(), - publish: vi.fn(), - tryAppendResolvedItem: (_identitySizeBound, body, resolveIdentity) => { - const identity = resolveIdentity(journal) - if (identity) { - persisted.set(agentJournalItemKey(identity), body) - } - return { accepted: true } - } - } - const first = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'first' }) - spawnToolCall(first, 'toolu-first') - first.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'reused-after-reconnect', - tool_use_id: 'toolu-first', - task_type: 'local_bash', - is_backgrounded: true - }) - ) - first.handle( - systemFrame({ - subtype: 'task_notification', - task_id: 'reused-after-reconnect', - tool_use_id: 'toolu-first', - status: 'failed', - summary: 'first run failed' - }) - ) - first.dispose() - - const resumed = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'resumed' }) - spawnToolCall(resumed, 'toolu-first') - resumed.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'reused-after-reconnect', - tool_use_id: 'toolu-first', - task_type: 'local_bash', - is_backgrounded: true - }) - ) - expect([...persisted.keys()].filter((key) => key.includes('reused-after-reconnect'))).toEqual([ - 'orca:claude-background-task%3Areused-after-reconnect' - ]) - resumed.dispose() - - const second = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'second' }) - spawnToolCall(second, 'toolu-second') - second.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'reused-after-reconnect', - tool_use_id: 'toolu-second', - task_type: 'local_bash', - is_backgrounded: true - }) - ) - - const rows = [...persisted.entries()].filter(([key]) => key.includes('reused-after-reconnect')) - expect(rows.map(([key]) => key)).toEqual([ - 'orca:claude-background-task%3Areused-after-reconnect', - 'orca:claude-background-task%3Areused-after-reconnect%232' - ]) - }) - - it('prints the provider sentence once instead of the opcode twice', () => { - const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness() - playFailedBackgroundCommand(translator) - - expect(fallbackRows()).toEqual([]) - expect(new Set(taskRowIds())).toEqual(new Set([`claude-background-task:${TASK_ID}`])) - expect(taskRowTexts().at(-1)).toBe(SUMMARY) - }) - - it('reports a failure for a task nothing in this transcript ever admitted', () => { - const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness() - translator.handle( - systemFrame({ - subtype: 'task_notification', - task_id: 'bjzenpq13', - tool_use_id: 'toolu_01ASNfnDBEzt4w3ejLE12bGu', - status: 'failed', - output_file: '', - summary: 'Locate the exact screenshot session', - uuid: '1d748563-5741-4aa8-9c21-7023b90bc737' - }) - ) - - expect(taskRowIds()).toEqual(['claude-background-task:bjzenpq13']) - expect(taskRowTexts().at(-1)).toBe('Locate the exact screenshot session') - expect(fallbackRows()).toEqual([]) - }) - - it('keeps the aggregate roster frame off the transcript even when it carries a failure', () => { - const { translator, fallbackRows, taskRowIds } = harness() - translator.handle( - systemFrame({ - subtype: 'background_tasks_changed', - tasks: [{ task_id: TASK_ID, status: 'failed', task_type: 'local_bash' }] - }) - ) - expect(fallbackRows()).toEqual([]) - expect(taskRowIds()).toEqual([]) - }) - - it('keeps a refused live task failure on one typed terminal row', () => { - const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness() - fillLiveTaskRows(translator) - - spawnToolCall(translator, 'toolu-overflow') - translator.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'overflow-fallback', - tool_use_id: 'toolu-overflow', - task_type: 'local_bash', - description: 'overflow task', - is_backgrounded: true - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_updated', - task_id: 'overflow-fallback', - patch: { status: 'failed' } - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_notification', - task_id: 'overflow-fallback', - tool_use_id: 'toolu-overflow', - status: 'failed', - summary: 'overflow failed' - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_progress', - task_id: 'overflow-fallback', - usage: { total_tokens: 3 } - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_notification', - task_id: 'overflow-fallback', - status: 'failed', - summary: 'duplicate overflow failed' - }) - ) - - expect(fallbackRows()).toEqual([]) - expect( - taskRowIds().filter((id) => id === 'claude-background-task:overflow-fallback') - ).toHaveLength(2) - expect(taskRowTexts().at(-1)).toBe('overflow failed') - }) - - it('prints a notification-first failure only once when every typed slot is live', () => { - const { translator, fallbackRows, taskRowIds } = harness() - fillLiveTaskRows(translator) - const notification = systemFrame({ - subtype: 'task_notification', - task_id: 'orphan-overflow', - status: 'failed', - summary: 'orphan overflow failed' - }) - - translator.handle(notification) - translator.handle(notification) - - expect(fallbackRows()).toEqual([]) - expect( - taskRowIds().filter((id) => id === 'claude-background-task:orphan-overflow') - ).toHaveLength(1) - }) - - it('keeps fallback ownership when a finished overflow task redelivers its start', () => { - const { translator, fallbackRows, taskRowIds } = harness() - fillLiveTaskRows(translator) - spawnToolCall(translator, 'toolu-overflow') - const start = systemFrame({ - subtype: 'task_started', - task_id: 'overflow-redelivery', - tool_use_id: 'toolu-overflow', - task_type: 'local_bash', - is_backgrounded: true - }) - const notification = systemFrame({ - subtype: 'task_notification', - task_id: 'overflow-redelivery', - tool_use_id: 'toolu-overflow', - status: 'failed', - summary: 'overflow redelivery failed' - }) - translator.handle(start) - translator.handle(notification) - translator.handle(start) - translator.handle( - systemFrame({ subtype: 'task_notification', task_id: 'live-0', status: 'completed' }) - ) - translator.handle(notification) - - expect(fallbackRows()).toEqual([]) - expect( - taskRowIds().filter((id) => id === 'claude-background-task:overflow-redelivery') - ).toHaveLength(1) - }) - - it.each(['completed', 'stopped'])( - 'reports a capacity-refused %s task without a generic frame row', - (status) => { - const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness() - fillLiveTaskRows(translator) - translator.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'overflow-success', - task_type: 'local_bash', - is_backgrounded: true - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_updated', - task_id: 'overflow-success', - patch: { status } - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_notification', - task_id: 'overflow-success', - status, - summary: `task ${status}` - }) - ) - - expect(fallbackRows()).toEqual([]) - expect( - taskRowIds().filter((id) => id === 'claude-background-task:overflow-success') - ).toHaveLength(2) - expect(taskRowTexts().at(-1)).toBe(`task ${status}`) - } - ) - - it('settles live background rows when the provider ends before disposal', () => { - const { translator, taskRowTexts } = harness() - spawnToolCall(translator) - translator.handle( - systemFrame({ - subtype: 'task_started', - task_id: TASK_ID, - tool_use_id: 'toolu_01CqPd7y', - task_type: 'local_bash', - description: 'Wait for the verification verdict', - is_backgrounded: true - }) - ) - - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) - - expect(taskRowTexts().at(-1)).toBe( - 'Background command "Wait for the verification verdict" stopped reporting' - ) - }) - it('keeps a nested child spawned inside a sidechain off the top-level transcript', () => { - const { translator, taskRowIds, fallbackRows } = harness() - translator.handle({ - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'assistant', - uuid: 'nested-assistant', - session_id: 'claude-session', - parent_tool_use_id: 'toolu_parent_agent', - message: { - role: 'assistant', - content: [ - { type: 'tool_use', id: 'toolu_sidechain', name: 'Bash', input: { command: 'wait' } } - ] - } - } - }) - translator.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'nested-1', - tool_use_id: 'toolu_sidechain', - task_type: 'local_bash', - description: 'nested work', - is_backgrounded: true - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_notification', - task_id: 'nested-1', - tool_use_id: 'toolu_sidechain', - status: 'failed', - summary: 'nested child failed' - }) - ) - - expect(taskRowIds()).toEqual([]) - expect(fallbackRows()).toEqual([]) - }) - - it('keeps a monitor off the timeline entirely', () => { - const { translator, taskRowIds, fallbackRows } = harness() - spawnToolCall(translator) - translator.handle( - systemFrame({ - subtype: 'task_started', - task_id: 'monitor-1', - tool_use_id: 'toolu_01CqPd7y', - task_type: 'monitor', - description: 'Watch the build', - is_backgrounded: true - }) - ) - translator.handle( - systemFrame({ - subtype: 'task_notification', - task_id: 'monitor-1', - tool_use_id: 'toolu_01CqPd7y', - status: 'failed', - summary: 'monitor stopped' - }) - ) - - expect(taskRowIds()).toEqual([]) - expect(fallbackRows()).toEqual([]) - }) - - it('keeps a monitor without a start frame owned by its top-level tool result', () => { - const { translator, taskRowIds, fallbackRows } = harness() - const toolUseId = 'toolu-monitor' - const taskId = 'bm5w1s2mv' - translator.handle({ - type: 'message', - sessionId: 'orca-session', - message: { - type: 'assistant', - uuid: 'monitor-call', - session_id: 'claude-session', - parent_tool_use_id: null, - message: { - role: 'assistant', - content: [ - { type: 'tool_use', id: toolUseId, name: 'Monitor', input: { persistent: true } } - ] - } - } - }) - translator.handle({ - type: 'message', - sessionId: 'orca-session', - message: { - type: 'user', - uuid: 'monitor-result', - session_id: 'claude-session', - parent_tool_use_id: null, - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'Monitor started' }] - }, - tool_use_result: { taskId, timeoutMs: 0, persistent: true } - } - }) - translator.handle( - systemFrame({ subtype: 'task_updated', task_id: taskId, patch: { status: 'completed' } }) - ) - translator.handle( - systemFrame({ - subtype: 'task_notification', - task_id: taskId, - tool_use_id: '', - status: 'completed', - summary: 'Monitor event: workflow journal results' - }) - ) - - expect(taskRowIds()).toEqual([]) - expect(fallbackRows()).toEqual([]) - }) -}) diff --git a/src/main/claude/structured/journal/claude-structured-journal-translation.test.ts b/src/main/claude/structured/journal/claude-structured-journal-translation.test.ts index 8e04780b115..5e42ac19f19 100644 --- a/src/main/claude/structured/journal/claude-structured-journal-translation.test.ts +++ b/src/main/claude/structured/journal/claude-structured-journal-translation.test.ts @@ -6,19 +6,10 @@ import type { AgentJournalItemBody, AgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-types' -import type { - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../../shared/agent-session/journal/agent-session-journal-identity' -import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key' -import { activeStructuredAgentSessionTurnId } from '../../../../shared/agent-session/view/structured-agent-session-live-turn' +import type { AgentSessionJournalIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity' 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 { createDeferredStructuredAgentSessionEventSink } from '../../../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudePendingPrompt } from '../../claude-prompt-registry' -import { readAgentJournalTurn } from '../../../../shared/agent-session/lifecycle/agent-session-turn-record' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { closeTestJournalHostDatabases, @@ -27,47 +18,6 @@ import { import { testEventSinkLogging } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding' -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 lifecycleAppends( - items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] -) { - return items.flatMap((item) => { - const identity = item.identity - const turn = identity.provider === 'legacy' ? readAgentJournalTurn(item.body) : null - return turn && identity.provider === 'legacy' ? [[identity.recordId, turn.state]] : [] - }) -} - -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 } - } - } -} - // Claude Code 2.1.258 / SDK 0.3.251 capture: only message.id links partial and final frames. function streamEvent(uuid: string, event: Record) { return { @@ -190,65 +140,6 @@ afterEach(async () => { }) 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'] - }) - // The block's source, which its saved transcript row also carries; never a stream frame's uuid. - const streamedIdentity = { - provider: 'claude', - sessionId: 'claude-session', - uuid: 'block:msg_01:0' - } - - for (const event of turn.start) { - translator.handle(event) - } - expect(lifecycleAppends(state.items)).toEqual([ - ['turn-lifecycle:msg_01-message-start', 'running'] - ]) - expect(assistantMessages(state.items)).toEqual([]) - - for (const delta of turn.deltas) { - translator.handle(delta) - } - expect(assistantMessages(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, @@ -356,500 +247,6 @@ describe('Claude structured journal translation', () => { ]) await reopened.close() }) - - 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([]) - expect(state.items.some((item) => item.body.kind === 'status')).toBe(false) - expect(state.tombstones).toEqual([]) - expect(lifecycleAppends(state.items)).toEqual([ - ['turn-lifecycle:user-replay-1', 'running'], - ['turn-lifecycle:user-replay-1', 'completed'], - ['turn-lifecycle:user-interrupt', 'running'], - ['turn-lifecycle:user-interrupt', 'interrupted'] - ]) - }) - - 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.items.at(-1)).toMatchObject({ - identity: { provider: 'legacy', recordId: 'turn-lifecycle:picker-command-1' }, - body: { kind: 'turn', turnId: 'picker-command-1', state: 'completed' } - }) - 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' }])) - 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' - }) - expect(lifecycleAppends(state.items).at(-1)).toEqual(['turn-lifecycle:user-1', 'completed']) - expect(state.items.findLast((item) => item.identity.provider === 'legacy')?.body).toMatchObject( - { kind: 'turn', state: 'completed', outcome: 'failure' } - ) - }) - - 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?.() - // Interrupts may deliver a result without a final assistant frame to reconcile streamed blocks. - translator.handle( - resultFrame('error_during_execution', { - is_error: true, - terminal_reason: 'aborted_streaming' - }) - ) - expect(translator.pendingStreamedBlocks).toBe(0) - } - - expect( - new Set(assistantMessages(state.items).map((item) => agentJournalItemKey(item.identity))).size - ).toBe(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.has('claude:claude-session:user-1')).toBe(false) - expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({ - kind: 'tool-call', - name: 'Bash', - callId: 'tool-1', - state: 'completed', - output: { head: 'a.ts\nb.ts', truncated: false } - }) - expect(state.items.some((item) => readAgentJournalTurn(item.body)?.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', - callId: 'tool-1', - 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).toEqual([]) - expect(lifecycleAppends(state.items).at(-1)).toEqual(['turn-lifecycle:user-1', 'completed']) - }) - - it('saves long thinking text whole', () => { - const state = sinkState() - const translator = createClaudeJournalTranslator({ sink: state.sink }) - const thinking = 'considering '.repeat(20_000) - - translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }])) - - const reasoning = state.items.find( - (item) => item.body.kind === 'message' && item.body.role === 'reasoning' - ) - expect(reasoning?.body).toEqual({ - kind: 'message', - role: 'reasoning', - blocks: [{ type: 'text', text: thinking }], - state: 'completed' - }) - }) - - 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: 'turn', - turnId: 'user-image', - state: 'running', - startedAt: expect.any(Number), - userItemId: 'claude:claude-session:user-image' - }) - }) - - 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', - callId: 'tool-1', - state: 'completed', - output: { head: 'done' } - }) - expect(state.items.some((item) => readAgentJournalTurn(item.body) !== null)).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', - '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.items.at(-1)?.body).toMatchObject({ - kind: 'question', - resolution: { state: 'cancelled' } - }) - expect(state.tombstones).toHaveLength(0) - }) }) function prompt( diff --git a/src/main/claude/structured/journal/claude-structured-prompt-items.test.ts b/src/main/claude/structured/journal/claude-structured-prompt-items.test.ts deleted file mode 100644 index e7f644420ed..00000000000 --- a/src/main/claude/structured/journal/claude-structured-prompt-items.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key' -import { cancelledJournalPromptBody } from '../../../native-chat/agent-session-journal/journal-prompt-body-bounds' -import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } from '../../../native-chat/agent-session-journal/journal-row-schema' -import { MAX_TOOL_DETAIL_LENGTH } from '../../../../shared/native-chat/tools/native-chat-tool-summary' -import { claudeApprovalItem, claudeQuestionItems } from './claude-structured-prompt-items' -import { buildClaudePromptReply } from './claude-structured-prompt-replies' -import type { ClaudePendingPrompt } from '../../claude-prompt-registry' - -function approvalPrompt( - input: Record, - presentation: Partial = {} -): ClaudePendingPrompt { - return { - requestId: 'approval-1', - promptKey: 'approval-1', - toolUseId: 'tool-approval', - toolName: 'ExitPlanMode', - kind: 'approval', - input, - suggestions: [], - questionIds: [], - settle: () => {}, - ...presentation - } -} - -describe('Claude structured approval presentation', () => { - it('journals the harness presentation instead of reconstructing from tool input', () => { - const prompt = approvalPrompt( - { file_path: '/repo/secrets.txt', content: 'export const token = 1' }, - { - toolName: 'Write', - title: 'Claude wants to write secrets.txt', - displayName: 'Write file', - description: 'Write access inside the workspace.', - decisionReason: 'The path requires approval.', - blockedPath: '/repo/secrets.txt', - matchedAskRule: { source: 'project', toolName: 'Write', ruleContent: 'ask' } - } - ) - - expect(claudeApprovalItem(prompt)).toMatchObject({ - kind: 'approval', - title: 'Claude wants to write secrets.txt', - displayName: 'Write file', - description: 'Write access inside the workspace.', - decisionReason: 'The path requires approval.', - blockedPath: '/repo/secrets.txt', - matchedAskRule: { source: 'project', toolName: 'Write', ruleContent: 'ask' }, - options: [ - { id: 'allow', label: 'Allow' }, - { id: 'allowForSession', label: 'Allow for this session' }, - { id: 'deny', label: 'Deny' } - ] - }) - }) - - it('journals a plan with typed presentation and readable compatibility detail', () => { - const prompt = approvalPrompt( - { plan: '# Release\n\n- Run tests', planFilePath: '/repo/plan.md' }, - { - title: 'Claude wants to present its plan', - subject: { kind: 'plan', text: '# Release\n\n- Run tests', filePath: '/repo/plan.md' } - } - ) - - expect(claudeApprovalItem(prompt)).toMatchObject({ - kind: 'approval', - title: 'Claude wants to present its plan', - subject: { kind: 'plan', text: '# Release\n\n- Run tests', filePath: '/repo/plan.md' }, - detail: '# Release\n\n- Run tests', - options: [ - { id: 'allow', label: 'Approve plan' }, - { id: 'deny', label: 'Keep planning' } - ] - }) - }) - - it('answers a dismissal as a plain deny, and a dismissed plan by asking Claude to wait', () => { - const tool = buildClaudePromptReply(approvalPrompt({ command: 'ls' }), { - kind: 'option', - optionId: 'cancel' - }) - const plan = buildClaudePromptReply( - approvalPrompt({ plan: '# Release' }, { subject: { kind: 'plan', text: '# Release' } }), - { kind: 'option', optionId: 'cancel' } - ) - - expect(tool).toEqual({ - behavior: 'deny', - message: 'User denied this action.', - toolUseID: expect.any(String) - }) - expect(plan).toMatchObject({ - behavior: 'deny', - message: expect.stringMatching(/wait for them/) - }) - expect(plan).not.toHaveProperty('interrupt') - }) - - it('uses a plan-specific fallback title when the harness omits one', () => { - const item = claudeApprovalItem( - approvalPrompt({ plan: '# Release' }, { subject: { kind: 'plan', text: '# Release' } }) - ) - - expect(item.title).toBe('Review proposed plan') - }) - - it.each([{ plan: '' }, {}])( - 'falls back to a reconstructed title when the harness sends no presentation', - (input) => { - const item = claudeApprovalItem(approvalPrompt(input)) - - expect(item.title).toBe('Allow ExitPlanMode?') - expect(item.detail).toContain('{') - expect(item.options[0]?.label).toBe('Allow') - } - ) - - it('caps an oversized generic payload with the shared tool-detail limit', () => { - const item = claudeApprovalItem( - approvalPrompt({ plan: '', payload: 'x'.repeat(MAX_TOOL_DETAIL_LENGTH * 2) }) - ) - - expect(item.detail?.length).toBeLessThanOrEqual(MAX_TOOL_DETAIL_LENGTH + 1) - expect(item.detail?.endsWith('…')).toBe(true) - }) - - it('keeps generic approval and denial behavior unchanged', () => { - const prompt = approvalPrompt( - { command: 'rm output.txt' }, - { - toolName: 'Bash', - suggestions: [{ type: 'addRules', rules: [], behavior: 'allow', destination: 'session' }] - } - ) - - expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'deny' })).toEqual({ - behavior: 'deny', - message: 'User denied this action.', - toolUseID: 'tool-approval' - }) - expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allowForSession' })).toEqual( - { - behavior: 'allow', - updatedInput: { command: 'rm output.txt' }, - updatedPermissions: [ - { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } - ], - toolUseID: 'tool-approval' - } - ) - }) - - it('keeps "Allow for this session" rules out of the settings files Claude suggests', () => { - const prompt = approvalPrompt( - { command: 'touch probe.txt' }, - { - toolName: 'Bash', - suggestions: [ - { - type: 'addRules', - rules: [{ toolName: 'Bash', ruleContent: 'touch probe.txt' }], - behavior: 'allow', - destination: 'localSettings' - }, - { type: 'addDirectories', directories: ['/repo/work'], destination: 'session' }, - { type: 'setMode', mode: 'acceptEdits', destination: 'session' } - ] - } - ) - - expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allowForSession' })).toEqual( - { - behavior: 'allow', - updatedInput: { command: 'touch probe.txt' }, - updatedPermissions: [ - { - type: 'addRules', - rules: [{ toolName: 'Bash', ruleContent: 'touch probe.txt' }], - behavior: 'allow', - destination: 'session' - }, - { type: 'addDirectories', directories: ['/repo/work'], destination: 'session' }, - { type: 'setMode', mode: 'acceptEdits', destination: 'session' } - ], - toolUseID: 'tool-approval' - } - ) - }) - - it('asks Claude to revise a rejected plan while accepting legacy session replies', () => { - const prompt = approvalPrompt( - { plan: '# Release' }, - { - subject: { kind: 'plan', text: '# Release' }, - suggestions: [{ type: 'addRules', rules: [], behavior: 'allow', destination: 'session' }] - } - ) - - expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'deny' })).toEqual({ - behavior: 'deny', - message: 'The user asked you to keep planning. Revise the plan and call ExitPlanMode again.', - toolUseID: 'tool-approval' - }) - expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allowForSession' })).toEqual( - { - behavior: 'allow', - updatedInput: { plan: '# Release' }, - toolUseID: 'tool-approval' - } - ) - }) -}) - -describe('Claude structured question addressing', () => { - it('bounds a valid grouped question before cancellation enters a lifecycle batch', () => { - const oversized = 'large prompt text '.repeat(40_000) - const questions = Array.from({ length: 4 }, (_, questionIndex) => ({ - question: `${questionIndex}:${oversized}`, - header: oversized, - options: Array.from({ length: 4 }, (_, optionIndex) => ({ - label: `${optionIndex}:${oversized}`, - description: oversized - })) - })) - const prompt: ClaudePendingPrompt = { - requestId: 'oversized-question', - promptKey: 'oversized-question', - toolUseId: 'tool-oversized', - toolName: 'AskUserQuestion', - kind: 'question', - input: { questions }, - suggestions: [], - questionIds: questions.map((question) => question.question), - settle: () => {} - } - - const body = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]?.body - if (!body) { - throw new Error('expected grouped question body') - } - const cancelled = cancelledJournalPromptBody(body) - if (!cancelled) { - throw new Error('expected cancellable grouped question body') - } - - expect(body.questions).toHaveLength(4) - expect(body.questions?.[0]?.question).toContain('[Orca: output truncated') - expect(body.questions?.[0]?.options[0]?.description).toContain('[Orca: output truncated') - expect(Buffer.byteLength(JSON.stringify(cancelled), 'utf8') + 4_096).toBeLessThan( - MAX_JOURNAL_LIFECYCLE_BATCH_BYTES - ) - }) - - 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], - 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( - buildClaudePromptReply(prompt, { - kind: 'answers', - answers: [{ questionId: 'q1', optionIds: [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], - settle: () => {} - } - const answer = 'https://example.test:8443/path' - - expect( - buildClaudePromptReply(prompt, { - kind: 'answers', - answers: [{ questionId: 'q1', optionIds: [], other: 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], - settle: () => {} - } - const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]! - const questions = item.body.questions! - const answers = [ - { - 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(buildClaudePromptReply(prompt, { kind: 'answers', answers })).toMatchObject({ - updatedInput: { - answers: { - [multiQuestion]: ['frontend', 'backend'], - [singleQuestion]: 'safe', - [otherQuestion]: 'remote host' - } - } - }) - }) -})