diff --git a/config/path-literal-baseline.txt b/config/path-literal-baseline.txt index b76b7dbb4d2..be1593d8bc1 100644 --- a/config/path-literal-baseline.txt +++ b/config/path-literal-baseline.txt @@ -7,10 +7,6 @@ config/scripts/check-layout-writer-ratchets.mjs src/renderer/src/runtime/remote- config/scripts/pr-layout-oracle-route.mjs src/main/runtime/old-session-tabs-stream/ config/scripts/pr-layout-oracle-route.mjs src/renderer/src/runtime/remote-workspace-frozen/ config/scripts/run-ssh-staged-upload-reliability.mjs src/main/ssh/ssh-relay-install-namespace.test.ts -config/scripts/vitest-sqlite-runtime-files.mjs src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts -config/scripts/vitest-sqlite-runtime-files.mjs src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts -config/scripts/vitest-sqlite-runtime-files.mjs src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts -config/scripts/vitest-sqlite-runtime-files.mjs src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts config/tsconfig.node.json src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts config/tsconfig.node.json src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts config/tsconfig.node.json src/renderer/src/components/native-chat/structured-agent-question-projection.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 1539dd91477..d6a829462aa 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-09-11", + "updatedAt": "2026-10-13", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -1544,14 +1544,14 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. Each user Send on phone or desktop is a new action with a fresh id and one request; neither automatically resends an ambiguous action. The host retains the original action's outcome, and a later phone Send remains possible with equal text, after remount, or when phone storage fails. Repeated transmission of the same operation remains protected by host deduplication.", "oracle": "Inject adapter/RPC acknowledgement loss, caller replacement, logical-client close after response, auth recovery with a written request, missing host submissions, legacy pending rows, stale fences, expiry, mobile remount, and phone storage refusal. Assert at most one dispatch for repeated transmission of one operation. For a later phone Send, assert a fresh id and second delivery while the original host entry remains unknown; equal images use current uploaded paths and delivery fields follow current host capabilities.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-structured-send-new-action.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts ../mobile/src/mobile-web-shell/page-storage-keys.test.ts ../mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts ../mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx ../mobile/src/mobile-web-shell/bridge-host-init.test.ts ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2" ], "testFiles": [ "src/shared/agent-session-operation-ledger.test.ts", "src/shared/structured-agent-session-send-evidence.test.ts", "src/main/runtime/agent-session-operation-admission.test.ts", - "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts", + "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts", "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts", @@ -1648,13 +1648,13 @@ "summary": "Nineteen focused mobile files passed 241 tests for fresh Send identity, remount, attachment paths, storage refusal, mixed host capabilities, queued cards, and written-request ambiguity." }, { - "date": "2026-10-06", + "date": "2026-10-13", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", - "durationSeconds": 14, - "summary": "Eleven focused host, shared, renderer, and orchestration files passed 173 tests after the desktop outbox gave way to the in-memory sender, which makes one request per send and never resends." + "durationSeconds": 6.47, + "summary": "Eleven focused host, shared, renderer and orchestration files passed 196 tests, including unknown send outcomes, reused message IDs and host replay without duplicate dispatch. The send-idempotency suite pins only that an unknown outcome is never resent; the removed refusal-retry matrix has no replacement here." } ], "runtimeBudget": { diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 0293f9a4f4b..06634db16dc 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -89,10 +89,9 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', + 'src/main/native-chat/structured-chat-naming.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts', - 'src/main/native-chat/structured-chat-naming-command-first.test.ts', - 'src/main/native-chat/structured-chat-naming-status-hook.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', 'src/main/runtime/structured-session-mail-redrive-wiring.test.ts', 'src/main/runtime/rpc/ws-transport.test.ts', diff --git a/config/scripts/vitest-runtime-boundary.test.ts b/config/scripts/vitest-runtime-boundary.test.ts index 6ff715a9255..737bd193c37 100644 --- a/config/scripts/vitest-runtime-boundary.test.ts +++ b/config/scripts/vitest-runtime-boundary.test.ts @@ -27,8 +27,7 @@ it('routes native-chat journal and record-store SQLite contracts through Node', expect.arrayContaining([ 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', - 'src/main/native-chat/structured-chat-naming-command-first.test.ts', - 'src/main/native-chat/structured-chat-naming-status-hook.test.ts', + 'src/main/native-chat/structured-chat-naming.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', 'src/main/runtime/structured-session-mail-redrive-wiring.test.ts' ]) diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 1cc0e2c026b..e802cbed3bc 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -188,7 +188,6 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-codex-held-sends.test.ts', 'src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts', 'src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts', - 'src/main/runtime/structured-agent-session-failure-logging.test.ts', 'src/main/runtime/structured-agent-session-held-signal.test.ts', 'src/main/runtime/structured-agent-session-host-refusal.test.ts', 'src/main/runtime/structured-agent-session-install-failure.test.ts', diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 66873733aad..e853d832434 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -2,6 +2,9 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ + 'src/main/pi/rpc-session-adapter.test.ts', + 'src/main/pi/rpc-session-startup.test.ts', + 'src/main/acp/acp-structured-publish-at-spawn.test.ts', 'tests/e2e/native-chat-startup-option-pickers.unit.test.ts', ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts', @@ -12,7 +15,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/repo-backfill-wsl-move.test.ts', 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', - 'src/main/pi/rpc-configured-default.test.ts', 'src/main/acp/acp-structured-omp-recordings.test.ts', 'src/main/acp/acp-text-stream-identity.test.ts', 'src/main/acp/acp-text-message-ownership.test.ts', @@ -22,6 +24,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/acp/acp-structured-prompt-failure.test.ts', 'src/main/acp/acp-timeline-turn-failures.test.ts', 'src/main/acp/acp-structured-session-adapter.test.ts', + 'src/main/acp/acp-structured-session-adapter-resume.test.ts', + 'src/main/acp/acp-structured-host-start-aborts.test.ts', 'src/main/acp/acp-conversation-copy.test.ts', 'src/main/pi/rpc-turn-races.test.ts', 'src/main/acp/acp-structured-child-session-end.test.ts', @@ -30,6 +34,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/acp/acp-structured-child-work.test.ts', 'src/main/acp/acp-structured-host-subagent-stop.test.ts', 'src/main/acp/acp-timeline-grok-subagent-retention.test.ts', + 'src/main/acp/acp-scripted-child-runtime.test.ts', 'src/main/active-view-persistence-boundary.test.ts', 'src/main/automations/automation-dispatch-host-fence.test.ts', 'src/main/automations/automation-owner-fencing.test.ts', @@ -61,8 +66,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/codex/codex-structured-journal-subagent-readers.test.ts', 'src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts', 'src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts', - 'src/main/codex/codex-structured-question-order.test.ts', - 'src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts', 'src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts', 'src/main/ipc/ssh/ssh-host-server-on-connect-wiring.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts', @@ -75,8 +78,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts', @@ -87,7 +88,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts', @@ -100,7 +100,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts', @@ -108,17 +107,12 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts', @@ -129,23 +123,18 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts', @@ -155,8 +144,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts', 'src/main/native-chat/native-chat-visuals-sweep.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts', @@ -169,7 +156,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts', @@ -179,18 +165,15 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts', @@ -231,7 +214,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-launch-directory.test.ts', 'src/main/runtime/agent-session-launch-env-admission.test.ts', 'src/main/runtime/agent-session-lease-renewal.test.ts', - 'src/main/runtime/agent-session-record-options.test.ts', 'src/main/runtime/agent-session-record-store-tab-table.test.ts', 'src/main/runtime/agent-session-record-store.test.ts', 'src/main/runtime/agent-session-permission-backfill.test.ts', @@ -245,6 +227,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/orca-runtime-terminal-surface-close.test.ts', 'src/main/runtime/orchestration/orchestration-party-location.test.ts', 'src/main/runtime/orchestration/structured-worker-journal-page.test.ts', + 'src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts', 'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts', 'src/main/runtime/rpc/methods/orchestration/federation/federation-agent-death-liveness.test.ts', 'tests/e2e/agent-launch-cli.unit.test.ts', @@ -261,7 +244,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts', - 'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts', @@ -273,6 +255,12 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts', 'src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts', 'src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts', + 'src/main/runtime/structured-agent-session-codex-scripted-child.test.ts', + 'src/main/runtime/structured-agent-session-codex-start-deadline.test.ts', + 'src/main/runtime/structured-agent-session-pi-startup.test.ts', + 'src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts', + 'src/main/runtime/structured-agent-session-adapter-start-failure.test.ts', + 'src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts', 'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts', 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 35543ac9476..a5b8977a85f 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -17,6 +17,7 @@ import { } from '../../../src/shared/agent-launch-runtime-capability' import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-optional-model-capability' import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -42,7 +43,9 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie // Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer. AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY, // Native chat offers no Stop for a turn the host restored but could not confirm. - AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, + // A new empty chat waits at rest and its first message starts the agent, as on desktop. + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY ]) export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts new file mode 100644 index 00000000000..c684ef15553 --- /dev/null +++ b/src/main/acp/acp-message-execution-options.ts @@ -0,0 +1,57 @@ +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { AcpAgentError } from './acp-errors' +import type { AcpStructuredSession } from './acp-structured-session' +import { acpPromptErrorDetail } from './acp-turn-failures' + +export async function applyAcpMessageOptions( + session: AcpStructuredSession, + options: AgentSessionExecutionOptions | undefined, + setOption: (key: 'model' | 'effort', value: string) => Promise, + isCurrent: () => boolean, + signal?: AbortSignal +): Promise { + if (!options) { + return true + } + const initial = session.options.messageDefault() + const model = options?.model ?? initial.model + const defaultEffort = + model === initial.model + ? initial.effort + : session.options.read().models.find((entry) => entry.id === model)?.defaultEffort + for (const key of ['model', 'effort'] as const) { + const value = options?.[key] ?? (key === 'model' ? initial.model : defaultEffort) + if (value === undefined) { + if (session.options.picked.has(key) || session.options.reported()[key] !== undefined) { + throw new AgentSessionOptionRejectedError( + new Error(`The default ${key} for this model is unknown`) + ) + } + continue + } + if (session.options.currentProof.has(key) && session.options.reported()[key] === value) { + continue + } + try { + await setOption(key, value) + } catch (error) { + signal?.throwIfAborted() + if (!isCurrent()) { + return false + } + if (error instanceof AcpAgentError) { + throw withProviderDiagnostic( + new AgentSessionOptionRejectedError(error), + providerDiagnostic(acpPromptErrorDetail(session.spec.dialect, error), 'person') + ) + } + throw new AgentSessionOptionRejectedError(error) + } + if (!isCurrent()) { + return false + } + } + return true +} diff --git a/src/main/acp/acp-peer-limits.ts b/src/main/acp/acp-peer-limits.ts index 51963e4791a..6a8f29a33dd 100644 --- a/src/main/acp/acp-peer-limits.ts +++ b/src/main/acp/acp-peer-limits.ts @@ -40,3 +40,16 @@ export function requestTimeout(value: number | null | undefined): number | null } return value } + +/** Each start step's own bound; turns are unbounded and option writes carry their caller's. */ +const ACP_START_STEP_TIMEOUT_MS: Readonly>> = { + initialize: 60_000, + 'session/new': 300_000, + 'session/load': 300_000, + 'session/resume': 300_000, + 'session/fork': 300_000 +} + +export function acpStepBound(method: string): { timeoutMs: number | null } { + return { timeoutMs: ACP_START_STEP_TIMEOUT_MS[method] ?? null } +} diff --git a/src/main/acp/acp-prompt-content.ts b/src/main/acp/acp-prompt-content.ts index 022bb4f83a8..00916c02ff6 100644 --- a/src/main/acp/acp-prompt-content.ts +++ b/src/main/acp/acp-prompt-content.ts @@ -21,7 +21,7 @@ import { } from '../../shared/node-bounded-file-reader' import type { AcpLaunchSpec } from './acp-launch-specs' import { resolveAcpPeerOptions } from './acp-peer-limits' -import { acpAgentName } from './acp-structured-acquire' +import { acpAgentName } from './acp-structured-agent-definitions' import type { AcpStructuredConnection } from './acp-structured-connection' import { acpSessionAcceptsImages } from './acp-structured-options' import type { ContentBlock } from './generated/acp-protocol.generated' diff --git a/src/main/acp/acp-response-settlement.ts b/src/main/acp/acp-response-settlement.ts index 2039403b63d..1ba34a47837 100644 --- a/src/main/acp/acp-response-settlement.ts +++ b/src/main/acp/acp-response-settlement.ts @@ -1,6 +1,7 @@ import type { z } from 'zod' import { AcpAgentError, AcpInvalidResponseError, acpAuthRequiredError } from './acp-errors' import type { AcpJsonRpcPeer } from './acp-json-rpc-peer' +import { acpStepBound } from './acp-peer-limits' export function callAcpSession( peer: AcpJsonRpcPeer, @@ -10,7 +11,7 @@ export function callAcpSession( answered: ((signedIn: boolean) => void) | undefined ): Promise { return peer.request(method, params, { - timeoutMs: null, + ...acpStepBound(method), settle: (response) => { if ('error' in response) { const error = response.error diff --git a/src/main/acp/acp-scripted-child-runtime.test.ts b/src/main/acp/acp-scripted-child-runtime.test.ts new file mode 100644 index 00000000000..7f593c2613e --- /dev/null +++ b/src/main/acp/acp-scripted-child-runtime.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from '../runtime/structured-agent-session-runtime' +import { acpScriptedChild } from './acp-scripted-child.test-fixture' + +let stateDirectory: string | undefined +let operation = 0 +const operationId = () => `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}` +const caller = { callerKey: 'scripted-acp-runtime' } + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + } + stateDirectory = undefined +}) + +describe.each(['grok', 'opencode'] as const)('%s runtime with a scripted ACP child', (agent) => { + it('attaches and accepts a send while startup is held, then delivers once the protocol session opens', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-scripted-acp-')) + const script = acpScriptedChild(agent)() + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory ?? '', + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + ...script.deps + }) + const attach = hostTestAttachParams(null, { + provider: agent, + agent, + providerHandle: undefined, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + }, + accountHome: + agent === 'grok' + ? { variable: 'GROK_HOME', path: stateDirectory } + : { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) + attach.envelope.clientOperationId = operationId() + const attached = await host.attach(caller, attach) + expect(attached).toMatchObject({ ok: true }) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + const body = hostTestMessage('message during startup') + const sent = await host.send(caller, { + body, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: attached.value.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + expect(sent).toMatchObject({ ok: true }) + expect(script.prompts()).toEqual([]) + await vi.waitFor(() => expect(script.spawns()).toBe(1)) + script.releaseHandshake() + await vi.waitFor(() => expect(script.prompts()).toEqual(['message during startup'])) + await host.close(HOST_TEST_SESSION, 'user-close') + expect(script.closes()).toBe(1) + }) +}) diff --git a/src/main/acp/acp-scripted-child.test-fixture.ts b/src/main/acp/acp-scripted-child.test-fixture.ts new file mode 100644 index 00000000000..c81d0ce4709 --- /dev/null +++ b/src/main/acp/acp-scripted-child.test-fixture.ts @@ -0,0 +1,151 @@ +import { z } from 'zod' +import type { + ScriptedAgentChild, + ScriptedAgentChildFactory +} from '../runtime/structured-agent-scripted-child.test-fixture' +import { FakeAcpChild, GROK_CONFIG_OPTIONS } from './acp-structured-adapter.test-support' +import type { FakeFrame } from './acp-scripted-agent.test-support' +import type { AcpLaunchSpec } from './acp-launch-specs' + +const promptParams = z.object({ + sessionId: z.string(), + prompt: z.array(z.object({ type: z.string(), text: z.string().optional() })), + _meta: z.object({ promptId: z.string() }).optional() +}) +const loadParams = z.object({ sessionId: z.string() }) +const configParams = z.object({ configId: z.string(), value: z.string() }) + +export function acpScriptedChild(agent: AcpLaunchSpec['agent']): ScriptedAgentChildFactory { + return () => { + let current: FakeAcpChild | undefined + let handshake: (() => void) | undefined + let released = false + let opened = 0 + let reopened = 0 + let closed = 0 + const texts: string[] = [] + const fixture: ScriptedAgentChild = { + holdHandshakes: true, + completeTurns: true, + deps: { + resolveEnvironment: async () => ({ PATH: '' }), + acpLaunchCommand: { + resolveCommand: () => 'scripted-acp-agent', + probeVersion: async () => true + }, + readProcessStartTime: async () => 1_700_000_000_000 + opened, + openAcpConnection: (launch, options) => { + opened += 1 + handshake = undefined + released = false + const child = new FakeAcpChild(launch, options) + current = child + let sessionId = `${agent}-scripted-session` + let running: FakeFrame | undefined + let configOptions = GROK_CONFIG_OPTIONS.map((option) => ({ ...option })) + const script = child.agent + script.on('initialize', (frame) => + script.reply(frame, { + protocolVersion: 1, + agentCapabilities: { loadSession: true } + }) + ) + const start = (frame: FakeFrame, resumes: boolean): void => { + if (resumes) { + reopened += 1 + sessionId = loadParams.parse(frame.params).sessionId + } + const answer = () => { + if (child.closed || child.exited) { + return + } + script.reply(frame, { ...(!resumes ? { sessionId } : {}), configOptions }) + } + if (fixture.holdHandshakes && !released) { + handshake = answer + } else { + answer() + } + } + script.on('session/new', (frame) => start(frame, false)) + script.on('session/load', (frame) => start(frame, true)) + script.on('session/set_config_option', (frame) => { + const { configId, value } = configParams.parse(frame.params) + configOptions = configOptions.map((option) => + option.id === configId ? { ...option, currentValue: value } : option + ) + script.reply(frame, { configOptions }) + }) + script.on('session/set_model', (frame) => script.reply(frame, {})) + // Grok's subagent-stop probe: the handler refuses the missing id, so stops are offered. + script.on('_x.ai/subagent/cancel', (frame) => + script.fail( + frame, + -32602, + 'Invalid params', + 'invalid params: missing field `subagentId`' + ) + ) + // Its background-task stop probe, answered the same way. + script.on('_x.ai/task/kill', (frame) => + script.fail( + frame, + -32602, + 'Invalid params', + 'invalid params: missing field `sessionId`' + ) + ) + script.on('session/prompt', (frame) => { + const params = promptParams.parse(frame.params) + texts.push(params.prompt.map((block) => block.text ?? '').join('')) + running = frame + script.notify('session/update', { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Scripted response' } + }, + ...(params._meta ? { _meta: params._meta } : {}) + }) + if (fixture.completeTurns) { + script.reply(frame, { stopReason: 'end_turn' }) + running = undefined + } + }) + script.on('session/cancel', () => { + if (running) { + script.reply(running, { stopReason: 'cancelled' }) + } + running = undefined + }) + const prove = child.proveClose + child.proveClose = async () => { + if (!child.exited) { + closed += 1 + } + return prove() + } + return child + } + }, + releaseHandshake: () => { + released = true + handshake?.() + handshake = undefined + }, + failHandshake: (message) => { + if (!current) { + throw new Error('no scripted ACP child') + } + current.stderr = message + current.exit() + handshake = undefined + }, + prompts: () => texts, + spawns: () => opened, + resumes: () => reopened, + closes: () => closed + } + return fixture + } +} diff --git a/src/main/acp/acp-scripted-connection.test-fixture.ts b/src/main/acp/acp-scripted-connection.test-fixture.ts new file mode 100644 index 00000000000..a5d2dc29953 --- /dev/null +++ b/src/main/acp/acp-scripted-connection.test-fixture.ts @@ -0,0 +1,93 @@ +import type { ProviderProcessExit } from '../provider-process/managed-provider-process' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { AcpAgentConnectionOptions } from './acp-agent-connection' +import { AcpConnectionClosedError } from './acp-errors' +import { AcpSessionRuntime } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import { AcpScriptedAgent } from './acp-scripted-agent.test-support' +export const PID = 4242 + +/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio, + * with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes + * the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */ +export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection { + readonly agent: AcpScriptedAgent + readonly pid: number | undefined = PID + readonly spawned = Promise.resolve() + stderr = '' + processTreeUnproven = false + private exitListeners: ((exit: ProviderProcessExit) => void)[] = [] + private gone = false + private closing = false + private lostWith: Error | undefined + closes = 0 + + constructor( + readonly launch: ProviderProcessLaunch, + private readonly connectionOptions: AcpAgentConnectionOptions, + agent = new AcpScriptedAgent() + ) { + const lifecycle: { child: FakeAcpChild | null } = { child: null } + super(agent.stdout, agent.stdin, { + ...connectionOptions, + peer: { ...connectionOptions.peer, closeOnInputEnd: false }, + onClose: (error) => lifecycle.child?.lost(error) + }) + lifecycle.child = this + this.agent = agent + } + + get exited() { + return this.gone + } + onExit(listener: (exit: ProviderProcessExit) => void): void { + if (this.gone) { + listener(FAKE_EXIT) + } else { + this.exitListeners.push(listener) + } + } + stderrTail(): string { + return this.stderr + } + pauseReading(): void { + this.agent.stdout.pause() + } + resumeReading(): void { + this.agent.stdout.resume() + } + /** What a close proves once the protocol is closed; replace it to leave the exit unproven. */ + proveClose = async (): Promise => { + this.exit() + return true + } + override close(error?: Error): Promise { + this.closes += 1 + this.closing ||= !this.gone + this.drainNotifications(error) + return this.proveClose().finally(() => super.close(error)) + } + /** The agent process ends on its own (or Orca's close landed). */ + exit(): void { + if (this.gone) { + return + } + this.gone = true + const error = + this.lostWith ?? new AcpConnectionClosedError(this.stderr || `${this.launch.command} exited`) + super.close(error) + this.connectionOptions.onExit?.(error, { expected: this.closing, exit: FAKE_EXIT }) + for (const listener of this.exitListeners.splice(0)) { + listener(FAKE_EXIT) + } + } + private lost(error: Error): void { + this.lostWith ??= error + if (this.closing || this.gone) { + return + } + this.connectionOptions.onClose?.(error) + } +} + +const FAKE_EXIT: ProviderProcessExit = { code: 0, signal: null, processless: false } diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index a3020620c1e..c253a95f088 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpJsonRpcPeer } from './acp-json-rpc-peer' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' -import { AcpConnectionClosedError, AcpRpcError } from './acp-errors' +import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' import type { RequestPermissionResponse } from './generated/acp-protocol.generated' const opened: { close: () => void }[] = [] @@ -219,18 +219,24 @@ describe('ACP caller-owned waits', () => { await rejected }) - it('lets initialize and explicit authentication wait for slow first-run startup and login', async () => { + it('bounds each start step on its own, never by the peer default', async () => { vi.useFakeTimers() const { agent, runtime } = fixture({ peer: { requestTimeoutMs: 20 } }) agent.on('initialize', () => {}) const initialized = runtime.initialize() - await vi.advanceTimersByTimeAsync(120_001) + await vi.advanceTimersByTimeAsync(59_000) agent.reply(agent.frames[0], { protocolVersion: 1 }) await initialized const authenticated = runtime.authenticate('caller-chosen') - await vi.advanceTimersByTimeAsync(120_001) + await vi.advanceTimersByTimeAsync(119_000) agent.reply(agent.frames[1], {}) await expect(authenticated).resolves.toEqual({}) + agent.on('session/new', () => {}) + const started = expect(runtime.start(startOptions)).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + await vi.advanceTimersByTimeAsync(300_000) + await started }) it('lets request owners withdraw vendor hooks after cancel', async () => { diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 98e78ac548a..7310f9687e5 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,13 +1,11 @@ // Making a reservation real for an ACP agent: open its connection (which spawns and owns the -// process), record it before any handshake, initialize with the client's file system and terminals -// off, then reattach the session this chat proved with `session/load` (`session/resume` for an -// agent that cannot load or prefers it) or start a new one. A saved session the agent cannot reopen -// is replaced by a new one, with a warning row that any later start writes if this attach never -// did. The journal already holds a reattached chat, so whatever the agent sends while it reattaches -// is not written, except context usage. The handshake has no time bound: the acquire's abort -// signal (Close, Stop, quit) stops it at any point. +// process) and record it before any handshake, so the host publishes the child at spawn. The +// handshake (`acp-structured-handshake`) runs after: initialize, then reattach the session this chat +// proved with `session/load` (`session/resume` for an agent that cannot load) or start a new one, +// and the host receives protocol facts once that session answers. The handshake has no time bound of +// its own: the host's startup limit, or a close, Stop or quit, stops it at any point. -import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { initializeAcpStructuredSession } from './acp-structured-handshake' import { AgentSessionPreSpawnError, type AgentSessionAcquisition, @@ -15,52 +13,48 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity' -import { AcpAgentError } from './acp-errors' import { acpPrivateTempConnect } from './acp-private-temp' -import { acpAuthenticationRequired, acpSignInRequiredRefusal } from './acp-turn-failures' import { - ACP_REOPEN_FAILED, - acpReopenTakeover, - acpSessionNotRestoredRow -} from './acp-session-reopen-failure' + assertAcpStartingSession, + isAcpReadySession, + type AcpStructuredChild +} from './acp-structured-child' import type { AcpSessionEvent } from './acp-session-runtime' import type { AcpStructuredConnection } from './acp-structured-connection' -import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions' +import { acpAgentName } from './acp-structured-agent-definitions' import { AcpStructuredLane, acpLaneChildWorkDelivery } from './acp-structured-lane' -import { NO_ACP_CHILD_STOPS, probeAcpChildStops } from './acp-structured-child-stop' +import { NO_ACP_CHILD_STOPS } from './acp-structured-child-stop' import { prepareAcpStructuredLaunch } from './acp-structured-launch-preparation' -import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { AcpStructuredOptions } from './acp-structured-options' import { AcpStructuredPrompts } from './acp-structured-prompts' -import { - asReattachHistory, - routeAcpSessionEvent, - type AcpStructuredSession -} from './acp-structured-session' +import { asReattachHistory, routeAcpSessionEvent } from './acp-structured-session' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' -import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' +import type { AcpStructuredTurnsDeps } from './acp-structured-turns' import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' /** Frames an agent may send before its session exists; past this they are dropped. */ const MAX_EARLY_FRAMES = 2_048 -export { acpAgentName } from './acp-structured-agent-definitions' export async function acquireAcpStructuredSession(input: { acquire: StructuredAgentSessionAcquireInput deps: AcpStructuredSessionAdapterDeps generation: string - /** The acquire was aborted; checked until the spawn, after which `track` hands it the connection. */ + /** The spawn was aborted; checked until the spawn, after which `track` hands it the connection. */ abandoned: () => boolean /** Registers the connection so a close during the acquire can stop it. */ track: (connection: AcpStructuredConnection) => void /** The process's exit, observed while or after the session exists. */ - onExit: (session: AcpStructuredSession | null) => void + onExit: (session: AcpStructuredChild | null) => void /** The protocol broke with the process perhaps still running. Null while starting: then the * start itself fails. */ - onConnectionLost: (session: AcpStructuredSession | null, error: Error) => void + onConnectionLost: (session: AcpStructuredChild | null, error: Error) => void onSettled: AcpStructuredTurnsDeps['settle'] forceClose: (sessionId: string) => void -}): Promise<{ acquisition: AgentSessionAcquisition; session: AcpStructuredSession }> { +}): Promise<{ + acquisition: AgentSessionAcquisition + session: AcpStructuredChild +}> { const { acquire, deps, generation } = input const { spec } = deps const agentName = acpAgentName(spec.agent) @@ -78,7 +72,7 @@ export async function acquireAcpStructuredSession(input: { } closedBeforeSpawn() const launch = await prepareAcpStructuredLaunch(deps, acquire, closedBeforeSpawn) - let session: AcpStructuredSession | null = null + let session: AcpStructuredChild | null = null // A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write. const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = { lane: null, @@ -89,9 +83,9 @@ export async function acquireAcpStructuredSession(input: { // has nobody waiting on it. What a settled request carries still shows in that turn. const prompts = new AcpStructuredPrompts( () => slot.lane, - () => session?.turns.acceptsRequests === true, + () => isAcpReadySession(session) && session.turns?.acceptsRequests === true, () => - session !== null && + isAcpReadySession(session) && (session.turns.acceptsRequests || (!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null)) ) @@ -117,10 +111,9 @@ export async function acquireAcpStructuredSession(input: { }, { clientInfo: { name: 'orca', version: '1' }, - ...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}), ...(spec.signIn ? { signIn: spec.signIn } : {}), onPermission: (request, context) => { - if (!session?.turns.acceptsRequests) { + if (!isAcpReadySession(session) || !session.turns?.acceptsRequests) { // No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer // is cutting it short: nobody is there to ask. return { outcome: { outcome: 'cancelled' } } @@ -167,11 +160,25 @@ export async function acquireAcpStructuredSession(input: { // unanswered fails at once and the process ends, whether or not its exit is proven yet. input.track(connection) connection.subscribe((event: AcpSessionEvent) => - whenLane( - () => - slot.lane && + whenLane(() => { + if (slot.lane) { routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) - ) + } + if ( + isAcpReadySession(session) && + !session.ended && + event.kind === 'known' && + event.notification.update.sessionUpdate === 'config_option_update' + ) { + deps.onEvent?.({ + type: 'options-reported', + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + reportedOptions: options.read().current + }) + } + }) ) const identity = providerSpawnedProcessIdentity( acquire, @@ -179,7 +186,6 @@ export async function acquireAcpStructuredSession(input: { deps.readProcessStartTime ) /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ - let childStops = NO_ACP_CHILD_STOPS const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { const lane = new AcpStructuredLane({ sink, @@ -190,10 +196,14 @@ export async function acquireAcpStructuredSession(input: { providerSessionId, dialect: spec.dialect, now, - childStops: () => childStops, + childStops: () => session?.childStops ?? NO_ACP_CHILD_STOPS, ...acpLaneChildWorkDelivery(deps, sessionId, events), logger: deps.logger ?? createStructuredAgentSessionLogger(), - onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId), + onInputAccepted: (clientMessageId) => { + if (isAcpReadySession(session)) { + session.turns?.accept(clientMessageId) + } + }, onFailed: () => input.forceClose(sessionId) }) slot.lane = lane @@ -214,125 +224,53 @@ export async function acquireAcpStructuredSession(input: { ) } await identity.onSpawned(pid) - try { - const initialized = await connection.initialize() - // Chosen here, on the machine Grok runs on, from the environment it was launched with. - const authMethodId = spec.authMethod?.({ - advertised: (initialized.authMethods ?? []).map((method) => method.id), - env: launch.env - }) - const setup = { authMethodId, resumePreference: spec.resumePreference } - const resume = launch.resume - let started: Awaited> | null = null - let liveLane: AcpStructuredLane | null = null - let takeover: ReturnType | null = null - if (resume) { - const attaching = makeLane(resume.sessionId, true) - liveLane = attaching - try { - started = await connection.start({ - cwd: launch.cwd, - mcpServers: [], - sessionId: resume.sessionId, - ...setup - }) - slot.reattaching = false - attaching.translator.finishLoad() - } catch (error) { - takeover = acpReopenTakeover(error, resume, { - over: connection.closed || acquire.signal?.aborted === true, - now: now(), - warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId }) - }) - // A new session takes the old one's place, with a new lane, so nothing of the failed - // attach's window outlives it. - } - } - if (!started || !liveLane) { - liveLane?.dispose() - slot.lane = null - started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...setup }) - liveLane = makeLane(started.sessionId) - } - childStops = await probeAcpChildStops(connection, spec.dialect) - options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') - liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) - const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) - const mode = spec.permissionModeConfig - if (mode) { - // Unlike a saved pick, a refusal fails the start: the session would run another posture. - // A start the host names no mode for asks, never more access than the chat chose. - const requested = mode.values[acquire.permissionMode ?? 'ask'] ?? mode.values.ask - const configId = options.modeConfigId() - const { configOptions } = await connection.setConfigOption(configId, requested) - if (configOptions.find((option) => option.id === configId)?.currentValue !== requested) { - throw new Error('Permission mode could not be confirmed') - } - options.adoptConfigOptions(configOptions) - } - const process = await identity.read(pid) - const link: AgentSessionProviderHandleLink = { - linkId: - deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), - handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId }, - origin: started.kind === 'new' ? 'created' : 'resumed', - mintedAtFence: acquire.fence, - observedAt: now(), - ...takeover - } - session = { - sessionId, - fence: acquire.fence, - acquisitionGeneration: generation, - spec, - childStops, - connection, - lane: liveLane, - prompts, - options, - turns: new AcpStructuredTurns({ + const process = await identity.read(pid) + const child: AcpStructuredChild = { + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + spec, + connection, + lane: null, + turns: null, + options, + prompts, + childStops: undefined, + closeRequested: false, + journalClosed: null, + ended: false, + exitObservedAt: null, + open: (signal) => + initializeAcpStructuredSession({ + acquire, + deps, + generation, + launch, connection, - withdrawRequests: () => prompts.withdrawAll(), - lane: liveLane, - agentName, - now, - settle: input.onSettled + options, + prompts, + child, + abandoned: () => signal.aborted, + lanes: { + create: makeLane, + clear: () => { + slot.lane = null + }, + finishLoad: () => { + slot.reattaching = false + } + }, + onSettled: input.onSettled }), - restoreSkipped, - closeRequested: false, - journalClosed: null, - ended: false, - exitObservedAt: null + dispose: () => { + slot.lane?.dispose() + early.length = 0 } - const reading = liveLane - const unbind = events?.bindReadingControl?.({ - pauseReading: () => connection.pauseReading(), - resumeReading: () => { - connection.resumeReading() - reading.retry() - } - }) - if (unbind) { - session.unbindReadingControl = unbind - } - if (connection.exited || connection.closed) { - throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) - } - // The chat says once per lost conversation that the agent forgot it, this start's loss included. - const lost = [ - ...(launch.resume?.unannouncedLosses() ?? []), - ...(takeover?.replaces ? [takeover.replaces.key] : []) - ] - for (const key of lost) { - liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName)) - } - return { acquisition: { process, link, acquisitionGeneration: generation }, session } - } catch (error) { - session = null - slot.lane?.dispose() - if (error instanceof AcpAgentError && acpAuthenticationRequired(spec.dialect, error)) { - throw acpSignInRequiredRefusal(spec.agent, spec.dialect, error) - } - throw error + } + session = child + assertAcpStartingSession(child, input.abandoned) + return { + acquisition: { process, acquisitionGeneration: generation }, + session: child } } diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 1682e9edfeb..7516c9b0d39 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -1,25 +1,23 @@ +import { FakeAcpChild } from './acp-scripted-connection.test-fixture' +export { FakeAcpChild, PID } from './acp-scripted-connection.test-fixture' // A scripted ACP agent behind the real adapter: a fake connection over in-memory stdio, the real // protocol runtime, translator and assembler, and a real on-disk journal to read back. import { tmpdir } from 'node:os' import { join } from 'node:path' import { vi } from 'vitest' +import { z } from 'zod' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' -import type { ProviderProcessExit } from '../provider-process/managed-provider-process' -import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { openProviderTimelineRig, SESSION, type ProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' -import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' -import type { AcpAgentConnectionOptions } from './acp-agent-connection' -import { AcpConnectionClosedError } from './acp-errors' -import { AcpSessionRuntime } from './acp-session-runtime' -import type { AcpStructuredConnection } from './acp-structured-connection' +import { type AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import { AcpStructuredSessionAdapter } from './acp-structured-session-adapter' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' @@ -27,7 +25,6 @@ import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-a export const GROK = acpLaunchSpecFor('grok')! const RIG_ENV = { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' } export const PROVIDER_SESSION = 'acp-session-1' -export const PID = 4242 export const GROK_CONFIG_OPTIONS = [ { @@ -54,102 +51,22 @@ export const GROK_CONFIG_OPTIONS = [ } ] -/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio, - * with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes - * the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */ -export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection { - readonly agent: AcpScriptedAgent - readonly pid: number | undefined = PID - readonly spawned = Promise.resolve() - stderr = '' - processTreeUnproven = false - private exitListeners: ((exit: ProviderProcessExit) => void)[] = [] - private gone = false - private closing = false - private lostWith: Error | undefined - closes = 0 - - constructor( - readonly launch: ProviderProcessLaunch, - private readonly connectionOptions: AcpAgentConnectionOptions, - agent = new AcpScriptedAgent() - ) { - const lifecycle: { child: FakeAcpChild | null } = { child: null } - super(agent.stdout, agent.stdin, { - ...connectionOptions, - peer: { ...connectionOptions.peer, closeOnInputEnd: false }, - onClose: (error) => lifecycle.child?.lost(error) - }) - lifecycle.child = this - this.agent = agent - } - - get exited() { - return this.gone - } - onExit(listener: (exit: ProviderProcessExit) => void): void { - if (this.gone) { - listener(FAKE_EXIT) - } else { - this.exitListeners.push(listener) - } - } - stderrTail(): string { - return this.stderr - } - pauseReading(): void { - this.agent.stdout.pause() - } - resumeReading(): void { - this.agent.stdout.resume() - } - /** What a close proves once the protocol is closed; replace it to leave the exit unproven. */ - proveClose = async (): Promise => { - this.exit() - return true - } - override close(error?: Error): Promise { - this.closes += 1 - this.closing ||= !this.gone - this.drainNotifications(error) - return this.proveClose().finally(() => super.close(error)) - } - /** The agent process ends on its own (or Orca's close landed). */ - exit(): void { - if (this.gone) { - return - } - this.gone = true - const error = - this.lostWith ?? new AcpConnectionClosedError(this.stderr || `${this.launch.command} exited`) - super.close(error) - this.connectionOptions.onExit?.(error, { expected: this.closing, exit: FAKE_EXIT }) - for (const listener of this.exitListeners.splice(0)) { - listener(FAKE_EXIT) - } - } - private lost(error: Error): void { - this.lostWith ??= error - if (this.closing || this.gone) { - return - } - this.connectionOptions.onClose?.(error) - } -} - -const FAKE_EXIT: ProviderProcessExit = { code: 0, signal: null, processless: false } - export type AcpAdapterRig = { rig: ProviderTimelineRig adapter: AcpStructuredSessionAdapter child: () => FakeAcpChild spawned: string[] lifecycle: StructuredAgentSessionLifecycleEvent[] + readonly ended: Extract[] + started(): StructuredAgentSessionProviderOpenResult + openFailure(): unknown settled: Parameters>[0][] acquire(options?: { fence?: number onSpawned?: () => Promise signal?: AbortSignal + waitForStart?: boolean + options?: Readonly> permissionMode?: AgentChatPermissionMode }): ReturnType /** Frames Orca wrote to the agent with this method. */ @@ -174,6 +91,9 @@ export async function openAcpAdapterRig( const spawned: string[] = [] const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] + let openFailure: unknown + let opened: StructuredAgentSessionProviderOpenResult | null = null + const startListeners = new Set<() => void>() const spec = options.spec ?? GROK // A start readies the account folder its row pins; here a scratch one. const { prepare, resolve, environment } = spec.account @@ -217,11 +137,18 @@ export async function openAcpAdapterRig( }) }) const opened = { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS } + let configOptions = GROK_CONFIG_OPTIONS agent.on('session/new', (frame) => agent.reply(frame, opened)) agent.on('session/load', (frame) => agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) ) - // A row that enforces its permission mode sets it at every start; the agent confirms it. + agent.on('session/set_config_option', (frame) => { + const parsed = z.object({ configId: z.string(), value: z.string() }).parse(frame.params) + configOptions = configOptions.map((option) => + option.id === parsed.configId ? { ...option, currentValue: parsed.value } : option + ) + agent.reply(frame, { configOptions }) + }) if (spec.permissionModeConfig) { agent.confirmConfigOptions(GROK_CONFIG_OPTIONS) } @@ -230,11 +157,17 @@ export async function openAcpAdapterRig( }, readProcessStartTime: async () => 1_700_000_000_000, dataDirectory: join(tmpdir(), 'orca-acp-adapter-rig'), - onEvent: (event) => lifecycle.push(event), onDispatchSettledLate: (settlement) => settled.push(settlement), mintGeneration: () => 'gen-acp', now: () => 5_000, - ...options.deps + ...options.deps, + onEvent: (event) => { + lifecycle.push(event) + for (const listener of startListeners) { + listener() + } + options.deps?.onEvent?.(event) + } }) const frames = () => current?.agent.frames ?? [] return { @@ -248,9 +181,21 @@ export async function openAcpAdapterRig( }, spawned, lifecycle, + get ended() { + return lifecycle.filter((event) => event.type === 'ended') + }, + started: () => { + if (!opened) { + throw new Error('ACP session has not started') + } + return opened + }, + openFailure: () => openFailure, settled, - acquire: (acquireOptions = {}) => - adapter.acquire({ + acquire: async (acquireOptions = {}) => { + openFailure = undefined + opened = null + const acquisition = await adapter.acquire({ identity: { sessionId: SESSION, workspaceId: 'workspace-1', @@ -261,13 +206,29 @@ export async function openAcpAdapterRig( fence: acquireOptions.fence ?? 1, spawnToken: 'spawn-1', events: rig.eventSink, + ...(acquireOptions.options ? { options: acquireOptions.options } : {}), ...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}), ...(acquireOptions.permissionMode ? { permissionMode: acquireOptions.permissionMode } : {}), onSpawned: async () => { spawned.push('onSpawned') await acquireOptions.onSpawned?.() } - }), + }) + if (acquireOptions.waitForStart !== false) { + try { + opened = await adapter.open({ + sessionId: SESSION, + fence: acquireOptions.fence ?? 1, + acquisitionGeneration: acquisition.acquisitionGeneration, + signal: acquireOptions.signal ?? new AbortController().signal + }) + } catch (error) { + openFailure = error + await adapter.closeSession(SESSION) + } + } + return acquisition + }, sent: (method) => frames().filter((frame) => frame.method === method), frame: (method, index = 0) => waitFor(() => { diff --git a/src/main/acp/acp-structured-antigravity.test.ts b/src/main/acp/acp-structured-antigravity.test.ts index c713dc77182..025965cc010 100644 --- a/src/main/acp/acp-structured-antigravity.test.ts +++ b/src/main/acp/acp-structured-antigravity.test.ts @@ -116,7 +116,7 @@ it('launches on its own private folder with file sign-in and no other Google cre it('runs the chat permission mode picked mid-chat as the server mode on the next send', async () => { let resumed = false - const { rig, host, fence } = await openHostRig({ + const { rig, host, fence, ready } = await openHostRig({ spec: ANTIGRAVITY, initialize: RESUMES, script: (agent) => { @@ -137,6 +137,7 @@ it('runs the chat permission mode picked mid-chat as the server mode on the next providerHandle: undefined }) expect(await host.attach(CALLER, attach)).toMatchObject({ ok: true }) + await ready() resumed = true const modes: unknown[] = [] let child = rig.child() @@ -172,14 +173,16 @@ it('refuses a new start while its sign-in changes, and stops one already startin spec: ANTIGRAVITY, script: (agent) => agent.on('session/new', () => {}) }) - const starting = rig.acquire().catch((error: unknown) => error) + const starting = rig.acquire() await rig.frame('session/new') const signIn = deferred() const operation = gate.run( async () => {}, () => signIn.promise ) - expect(String(await starting)).toContain('closed while starting') + await starting + expect(rig.openFailure()).toMatchObject({ name: 'AcpConnectionClosedError' }) + expect(rig.child().exited).toBe(true) await expect(rig.acquire()).rejects.toMatchObject({ reason: 'accountSwitchInProgress' }) signIn.resolve() await operation diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts index 92f27513cd5..2d83832ea43 100644 --- a/src/main/acp/acp-structured-auth.test.ts +++ b/src/main/acp/acp-structured-auth.test.ts @@ -4,7 +4,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { z } from 'zod' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' import { GROK_CONFIG_OPTIONS, @@ -60,9 +59,9 @@ async function signInWith(options: { initialize: { authMethods: options.authMethods }, script: requiresSignIn }) - const acquired = await rig.acquire().catch((error: unknown) => error) + await rig.acquire() const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId) - return { rig, acquired, methods } + return { rig, acquired: rig.openFailure() ?? rig.started(), methods } } describe('Grok sign-in on its own machine', () => { @@ -92,7 +91,6 @@ describe('Grok sign-in on its own machine', () => { authMethods: [{ id: 'browser', name: 'Browser' }] }) expect(methods).toEqual([]) - expect(acquired).toBeInstanceOf(AgentSessionAcquisitionRefusal) expect(acquired).toMatchObject({ reason: 'notSignedIn' }) }) diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts index b47ffbc34ea..eb961c93ed1 100644 --- a/src/main/acp/acp-structured-broken-stream.test.ts +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -122,11 +122,11 @@ describe('ACP agent that closes its stdout but keeps running', () => { // Long enough for a closed stream to have reached the adapter. await new Promise((resolve) => setTimeout(resolve, 500)) expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) expect(alive(pid)).toBe(true) await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) expect(alive(pid)).toBe(false) - expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }, 30_000) }) @@ -162,8 +162,8 @@ describe('ACP agent that exits while a process it started holds its stdout open' }) await rig.acquire() await sendHello(rig, 'held') - await vi.waitFor(() => expect(rig.lifecycle).toHaveLength(1), { timeout: 10_000, interval: 20 }) - expect(rig.lifecycle[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) + await vi.waitFor(() => expect(rig.ended).toHaveLength(1), { timeout: 10_000, interval: 20 }) + expect(rig.ended[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) expect(alive(Number(readFileSync(pidFile, 'utf8')))).toBe(false) const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) diff --git a/src/main/acp/acp-structured-child-lifecycle.ts b/src/main/acp/acp-structured-child-lifecycle.ts new file mode 100644 index 00000000000..5c3017878fa --- /dev/null +++ b/src/main/acp/acp-structured-child-lifecycle.ts @@ -0,0 +1,140 @@ +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts' +import { isAcpReadySession, type AcpStructuredChild } from './acp-structured-child' +import type { AcpStructuredConnection } from './acp-structured-connection' +import { closeAcpSessionJournal, endAcpStructuredSession } from './acp-structured-session' +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { + ACP_STOP_GRACE_MS, + type AcpStructuredSessionAdapterDeps +} from './acp-structured-session-adapter-deps' +import { windDownAcpTurn } from './acp-structured-stop' + +export class AcpStructuredChildren { + readonly sessions = new Map() + readonly starts = new ProviderAcquisitionStarts() + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + /** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */ + async close(sessionId: string): Promise { + const connection = this.sessions.get(sessionId)?.connection + const closed = await this.stop(sessionId, true) + if (closed && connection?.processTreeUnproven) { + throw new AgentSessionAcquisitionRootExitObservedError( + new Error( + `${this.deps.spec.agent} ACP agent exited, but its process tree was not proven gone` + ) + ) + } + return closed + } + + /** True only once every child is proven gone, or when this adapter runs none for the session: + * a failed start's child, and the session's own. */ + async stop(sessionId: string, requested = true): Promise { + const [failedStart, session] = await Promise.all([ + this.starts.stopFailed(sessionId), + this.stopSession(sessionId, requested) + ]) + return failedStart && session + } + + private async stopSession(sessionId: string, requested: boolean): Promise { + const session = this.sessions.get(sessionId) + if (!session || session.ended) { + return true + } + if (!isAcpReadySession(session)) { + session.closeRequested ||= requested && session.journalClosed === null + session.dispose() + session.closing ??= session.connection + .close() + .then((proven) => { + if (proven) { + this.finish(session, session.exitObservedAt ?? this.now()) + } + return proven + }) + .finally(() => { + session.closing = undefined + }) + return session.closing + } + if (requested && session.journalClosed === null) { + // A close, dispose or quit ends a running turn as a Stop does before the child goes. + session.closeRequested = true + await windDownAcpTurn(session, this.stopGraceMs()) + if (session.ended) { + return true + } + } + // A connection loss already decided why the child ends; a later stop does not relabel it. + if (session.journalClosed === null) { + session.closeRequested ||= requested + session.lane.flush() + } + const proven = await session.connection.close() + if (proven) { + this.finish(session, session.exitObservedAt ?? this.now()) + } + return proven + } + + /** The child's exit is proven: the host hears it, and nothing of the child stays here. */ + finish(session: AcpStructuredChild, observedAt: number): void { + if (isAcpReadySession(session)) { + endAcpStructuredSession(session, observedAt, this.deps.onEvent) + } else if (!session.ended) { + session.ended = true + session.exitObservedAt = observedAt + session.dispose() + this.deps.onEvent?.({ + type: 'ended', + sessionId: session.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt, + cause: session.closeRequested ? 'requested-close' : 'unexpected-exit', + reason: + session.connection.stderrTail() || `${session.spec.agent} ACP agent ended while starting`, + failure: agentSessionFailureFact( + session.closeRequested ? 'hostStopped' : 'providerExited', + { + detail: providerDiagnostic(session.connection.stderrTail(), 'person') + } + ) + }) + } + if (this.sessions.get(session.sessionId) === session) { + this.sessions.delete(session.sessionId) + } + } + + /** The connection broke while the child may still run: nothing more it says can be journaled, so + * the journal closes now and the child is stopped; the host hears `ended` once that is proven. */ + connectionLost(session: AcpStructuredChild | null, error: Error): void { + if ( + !session || + this.sessions.get(session.sessionId) !== session || + session.journalClosed !== null + ) { + return + } + if (!isAcpReadySession(session)) { + session.journalClosed = error.message || error.name + return + } + closeAcpSessionJournal( + session, + `${session.spec.agent} ACP connection closed: ${error.message || error.name}`, + error + ) + void this.stop(session.sessionId, false) + } + + private stopGraceMs(): number { + return this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS + } + private now(): number { + return (this.deps.now ?? Date.now)() + } +} diff --git a/src/main/acp/acp-structured-child-stop.ts b/src/main/acp/acp-structured-child-stop.ts index 2ed2f7c40a8..e1c28fdf825 100644 --- a/src/main/acp/acp-structured-child-stop.ts +++ b/src/main/acp/acp-structured-child-stop.ts @@ -1,3 +1,4 @@ +import { isAcpReadySession, type AcpStructuredChild } from './acp-structured-child' import type { AcpStructuredSession } from './acp-structured-session' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' @@ -20,12 +21,13 @@ type BackgroundTaskStop = NonNullable export const NO_ACP_CHILD_STOPS: AcpChildStops = { subagents: false, backgroundTasks: false } export function acpChildStopCapabilities( - session?: AcpStructuredSession + session?: AcpStructuredChild ): AgentSessionBackgroundTaskStops | undefined { return session?.journalClosed === null ? { supportsTaskStop: - session.childStops?.subagents === true || session.childStops?.backgroundTasks === true, + isAcpReadySession(session) && + (session.childStops?.subagents === true || session.childStops?.backgroundTasks === true), supportsStopAll: false } : undefined diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts new file mode 100644 index 00000000000..78b3493e2d2 --- /dev/null +++ b/src/main/acp/acp-structured-child.ts @@ -0,0 +1,63 @@ +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { AcpAgentError } from './acp-errors' +import { acpAuthenticationRequired, acpSignInRequiredFailure } from './acp-turn-failures' +import type { AcpStructuredSession } from './acp-structured-session' + +export type AcpStructuredChild = Pick< + AcpStructuredSession, + | 'sessionId' + | 'fence' + | 'acquisitionGeneration' + | 'spec' + | 'connection' + | 'closeRequested' + | 'journalClosed' + | 'ended' + | 'exitObservedAt' +> & { + lane: AcpStructuredSession['lane'] | null + turns: AcpStructuredSession['turns'] | null + options: AcpStructuredSession['options'] + prompts: AcpStructuredSession['prompts'] + childStops?: AcpStructuredSession['childStops'] + unbindReadingControl?: () => void + open: (signal: AbortSignal) => Promise + closing?: Promise + dispose: () => void +} + +export function isAcpReadySession( + session: AcpStructuredChild | null | undefined +): session is AcpStructuredSession & AcpStructuredChild { + return session != null && session.lane !== null && session.turns !== null +} + +export function acpStartupFailure( + session: AcpStructuredChild, + error: unknown +): SubmissionRejectionFact | undefined { + if (error instanceof AcpAgentError && acpAuthenticationRequired(session.spec.dialect, error)) { + // The agent's sign-in words, never log lines it wrote before refusing. + return acpSignInRequiredFailure(session.spec.dialect, error) + } + return undefined +} + +export function assertAcpStartingSession( + session: AcpStructuredChild, + abandoned: () => boolean +): void { + if ( + session.ended || + session.closeRequested || + session.journalClosed !== null || + session.connection.exited || + session.connection.closed || + abandoned() + ) { + throw new Error( + session.connection.stderrTail() || `${session.spec.command} closed while starting` + ) + } +} diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts index ec56bb9104e..3ad8b8208a6 100644 --- a/src/main/acp/acp-structured-compaction.test.ts +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -8,7 +8,6 @@ import { import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' -import { structuredAgentSessionChildReportedSignedOut } from '../native-chat/agent-session-wire/structured-agent-session-outgrown-child' import { ACP_LAUNCH_SPECS, acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' @@ -276,27 +275,18 @@ describe('ACP compaction settlement', () => { it.each([ { spec: OPENCODE, code: -32000, data: undefined }, { spec: OMP, code: -32603, data: { details: 'No model selected.\n\nUse /login to sign in.' } } - ])( - 'says $spec.agent is not signed in, as a send does, so the next one starts a new agent', - async ({ spec, code, data }) => { - const { rig, command, prompt } = await compact(spec) - rig.child().agent.fail(prompt, code, 'Authentication required', data) - const { turn, inside } = await ended(rig, command) - expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) - expect(inside).toEqual([ - expect.objectContaining({ - tone: 'error', - failure: expect.objectContaining({ kind: 'notSignedIn' }) - }) - ]) - expect( - structuredAgentSessionChildReportedSignedOut({ - child: { generation: 'generation-1', fence: 1, phase: 'ready' }, - journal: rig.rig.journal - }) - ).toBe(true) - } - ) + ])('says $spec.agent is not signed in, as a send does', async ({ spec, code, data }) => { + const { rig, command, prompt } = await compact(spec) + rig.child().agent.fail(prompt, code, 'Authentication required', data) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(inside).toEqual([ + expect.objectContaining({ + tone: 'error', + failure: expect.objectContaining({ kind: 'notSignedIn' }) + }) + ]) + }) it('fails a compaction the agent ended with any other stop reason', async () => { const { rig, command, prompt } = await compact(GROK) diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts new file mode 100644 index 00000000000..e22a480e02b --- /dev/null +++ b/src/main/acp/acp-structured-handshake.ts @@ -0,0 +1,172 @@ +// An ACP child's handshake, run after the host has published it: initialize, reopen the chat's +// session or start a new one, then return its protocol facts to the host. A saved session the +// agent cannot reopen is replaced by a new one, with a warning row that any later start writes if +// this one never did; while a reopened session replays history the journal already holds, nothing +// it sends is written except context usage. + +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { + ACP_REOPEN_FAILED, + acpReopenTakeover, + acpSessionNotRestoredRow +} from './acp-session-reopen-failure' +import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions' +import { probeAcpChildStops } from './acp-structured-child-stop' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import type { AcpStructuredOptions } from './acp-structured-options' +import type { AcpStructuredPrompts } from './acp-structured-prompts' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' +import { + assertAcpStartingSession, + isAcpReadySession, + type AcpStructuredChild +} from './acp-structured-child' + +type AcpHandshakeInput = { + acquire: StructuredAgentSessionAcquireInput + deps: AcpStructuredSessionAdapterDeps + generation: string + launch: AcpStructuredLaunch + connection: AcpStructuredConnection + options: AcpStructuredOptions + prompts: AcpStructuredPrompts + child: AcpStructuredChild + abandoned: () => boolean + lanes: { + create: (providerSessionId: string, attaching?: boolean) => AcpStructuredLane + clear: () => void + finishLoad: () => void + } + onSettled: AcpStructuredTurnsDeps['settle'] +} + +export async function initializeAcpStructuredSession( + input: AcpHandshakeInput +): Promise { + const { acquire, deps, launch, connection, options, prompts, child, onSettled } = input + const { spec } = deps + const sessionId = acquire.identity.sessionId + const now = deps.now ?? Date.now + const assertStarting = () => assertAcpStartingSession(child, input.abandoned) + const agentName = acpAgentName(spec.agent) + assertStarting() + const initialized = await connection.initialize() + assertStarting() + // Chosen here, on the machine Grok runs on, from the environment it was launched with. + const authMethodId = spec.authMethod?.({ + advertised: (initialized.authMethods ?? []).map((method) => method.id), + env: launch.env + }) + const setup = { authMethodId, resumePreference: spec.resumePreference } + const resume = launch.resume + let started: Awaited> | null = null + let liveLane: AcpStructuredLane | null = null + let takeover: ReturnType | null = null + if (resume) { + const attaching = input.lanes.create(resume.sessionId, true) + liveLane = attaching + try { + started = await connection.start({ + cwd: launch.cwd, + mcpServers: [], + sessionId: resume.sessionId, + ...setup + }) + assertStarting() + input.lanes.finishLoad() + attaching.translator.finishLoad() + } catch (error) { + assertStarting() + takeover = acpReopenTakeover(error, resume, { + over: connection.closed || input.abandoned(), + now: now(), + warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId }) + }) + // A new session takes the old one's place, with a new lane, so nothing of the failed + // attach's window outlives it. + } + } + if (!started || !liveLane) { + liveLane?.dispose() + input.lanes.clear() + started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...setup }) + assertStarting() + liveLane = input.lanes.create(started.sessionId) + } + const childStops = await probeAcpChildStops(connection, spec.dialect) + assertStarting() + options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') + liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) + const mode = spec.permissionModeConfig + if (mode) { + // A refusal fails the start: the session would otherwise run another permission mode. + const requested = mode.values[acquire.permissionMode ?? 'ask'] ?? mode.values.ask + const configId = options.modeConfigId() + const { configOptions } = await connection.setConfigOption(configId, requested) + assertStarting() + if (configOptions.find((option) => option.id === configId)?.currentValue !== requested) { + throw new Error('Permission mode could not be confirmed') + } + options.adoptSession( + { ...started.response, configOptions }, + started.kind === 'new' ? 'new' : 'loaded' + ) + } + const link: AgentSessionProviderHandleLink = { + linkId: + deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), + handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId }, + origin: started.kind === 'new' ? 'created' : 'resumed', + mintedAtFence: acquire.fence, + observedAt: now(), + ...takeover + } + const reading = liveLane + const unbind = acquire.events?.bindReadingControl?.({ + pauseReading: () => connection.pauseReading(), + resumeReading: () => { + connection.resumeReading() + reading.retry() + } + }) + if (unbind) { + child.unbindReadingControl = unbind + } + if (connection.exited || connection.closed) { + throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) + } + // The chat says once per lost conversation that the agent forgot it, this start's loss included. + const lost = [ + ...(launch.resume?.unannouncedLosses() ?? []), + ...(takeover?.replaces ? [takeover.replaces.key] : []) + ] + for (const key of lost) { + liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName)) + } + assertStarting() + Object.assign(child, { + childStops, + lane: liveLane, + turns: new AcpStructuredTurns({ + connection, + withdrawRequests: () => prompts.withdrawAll(), + lane: liveLane, + agentName, + now, + settle: onSettled + }) + }) + if (!isAcpReadySession(child)) { + throw new Error('ACP session did not open') + } + return { + link, + reportedOptions: options.read().current, + catalogListing: options.startListing() + } +} diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index 22d6fdc73d2..d05a7cc0ba9 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -149,6 +149,10 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe ? rig.host.create(CALLER, createTestParams(create, attachParams()), { firstMessage: create }) : rig.host.attach(CALLER, attachParams()) expect(await opening).toMatchObject({ ok: true }) + if (!create) { + // The attach answers at spawn; what the chat runs is known once its start is proven. + await rig.ready() + } return { ...rig, catalog, listed } } diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index a1486cd3263..d0e66754800 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -57,18 +57,20 @@ describe('reopening a Grok chat through the host', () => { it('loads a chat the journal holds and writes its exchange once', async () => { const count = { loads: 0 } let resumed = false - const { host, fence, messages, exchange } = await openHostRig({ + const { host, fence, messages, exchange, ready } = await openHostRig({ initialize: RESUMES, script: loads('hi', count), deps: { resolveLaunch: launch(() => resumed) } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() resumed = true await exchange('hi', true) expect(await messages()).toEqual(['hello', 'hi']) const before = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() await host.flushStreamedEvents(SESSION) expect(count.loads).toBe(1) expect(await messages()).toEqual(['hello', 'hi']) @@ -82,12 +84,13 @@ describe('reopening a Grok chat through the host', () => { it('shows a reply a crash cut off with the existing notice, never completed from what Grok saved', async () => { let resumed = false - const { rig, host, fence, messages, exchange } = await openHostRig({ + const { rig, host, fence, messages, exchange, ready } = await openHostRig({ initialize: RESUMES, script: loads('complete saved reply', { loads: 0 }), deps: { resolveLaunch: launch(() => resumed) } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() resumed = true await exchange('complete', false) // Grok dies mid-reply; the host ends its record, which moves the chat's fence. @@ -95,6 +98,7 @@ describe('reopening a Grok chat through the host', () => { rig.child().exit() await waitFor(() => expect(fence()).toBeGreaterThan(cutAt)) expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() await host.flushStreamedEvents(SESSION) expect(await messages()).toEqual(['hello', 'complete']) const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items @@ -169,29 +173,28 @@ describe('a Grok crash whose exit is not proven yet', () => { }) describe('closing or stopping a Grok chat while it starts', () => { - it('keeps a failed start whose child is not proven gone; a close leaves it, the next start asks it again', async () => { - const { rig, host } = await openHostRig({ - script: (agent) => agent.on('initialize', () => {}) - }) - const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + it('retains a published startup child whose close is unproven and retries before another spawn', async () => { + const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}) }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) await rig.frame('initialize') const child = rig.child() child.proveClose = vi.fn(async () => false) - await host.close(SESSION, 'user-close') - expect(await attaching).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) - child.proveClose = vi.fn(async () => { - child.exit() - return true - }) - await host.close(SESSION, 'user-close') - expect(child.proveClose).not.toHaveBeenCalled() - expect(child.exited).toBe(false) - // As Claude's: the next start asks the child again before it spawns another. + try { + await expect(host.close(SESSION, 'user-close')).rejects.toThrow('eviction failed') + expect(child.exited).toBe(false) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + } finally { + child.proveClose = vi.fn(async () => { + child.exit() + return true + }) + await host.close(SESSION, 'user-close') + } + expect(child.exited).toBe(true) await host.send(CALLER, { envelope: envelope('agentSession.send', { body: hello }), body: hello }) - await waitFor(() => expect(child.exited).toBe(true)) await waitFor(() => expect(rig.child()).not.toBe(child)) await host.close(SESSION, 'user-close') }) @@ -205,7 +208,7 @@ describe('closing or stopping a Grok chat while it starts', () => { const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) // With no close behind it. await waitFor(() => expect(rig.child().exited).toBe(true)) - expect((await attaching).ok).toBe(false) + expect((await attaching).ok).toBe(true) expect(await stopping).toMatchObject({ ok: true }) expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) }) @@ -244,7 +247,7 @@ describe('closing or stopping a Grok chat while it starts', () => { await host.close(SESSION, 'user-close') }) - it('leaves a start alone for a Stop that names a turn of a child already gone', async () => { + it('leaves a starting child alone when Stop names an earlier turn', async () => { const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}) }) @@ -254,11 +257,12 @@ describe('closing or stopping a Grok chat while it starts', () => { envelope: envelope('agentSession.cancel', { turnId: 'turn-of-an-old-child' }), turnId: 'turn-of-an-old-child' }) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: false } }) await rig.settle() expect(rig.child().closes).toBe(0) + expect(rig.child().exited).toBe(false) await host.close(SESSION, 'user-close') await attaching - await stopping }) it('never spawns a child for a start closed while its launch was still resolving', async () => { diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index a519d9567d0..6808f129e92 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -47,11 +47,19 @@ describe('a live Grok listing', () => { stop: vi.fn(), providerStarted: vi.fn() } - // The rig's attach must succeed: bookkeeping never fails a proven start. - const { host, store } = await openAttachedHostRig({}, undefined, modelCatalog) + // The rig waits for the child to turn ready: bookkeeping never fails a proven start. + const { host, rig, store, fence } = await openAttachedHostRig({}, undefined, modelCatalog) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease.ownerProcess).not.toBeNull() - await expect(host.readOptions(SESSION)).resolves.toHaveProperty('models') + // The rest of the start still lands, and live options remain readable. + await expect(host.readOptions(SESSION)).resolves.toMatchObject({ current: expect.any(Object) }) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(2) + expect( + (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current + ).toMatchObject({ + model: 'grok-4.7', + effort: 'high' + }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) }) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 3a3352ce562..7792ae071c3 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -9,8 +9,9 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' -import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support' +import { framesOf, openAttachedHostRig, send, stop } from './acp-structured-host.test-support' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { STOP_NOTE_CANCELLATION_REQUESTED } from '../native-chat/agent-session-wire/structured-agent-session-turn-stop-notes' afterEach(async () => { await closeProviderTimelineRigs() @@ -40,54 +41,117 @@ async function within(promise: Promise, ms: number) { } } -const failed = (outcome: Awaited>) => - outcome !== 'pending' && - (outcome.settled === 'rejected' || - (typeof outcome.value === 'object' && outcome.value !== null && 'ok' in outcome.value - ? outcome.value.ok === false - : false)) - -describe('a Grok model pick Grok never answers', () => { - it('does not hold a Stop behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig() - const picking = pick(host, 'grok-4.6') +describe('a Grok model pick Grok never answers during send', () => { + it('does not hold Stop, writes one request note, and ignores a late setter reply', async () => { + const { rig, host, rows } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + await send(host, 'hello') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) - expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) - expect(failed(await within(picking, 1_000))).toBe(true) + const invalid = host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', {}, { payloadFingerprint: 'invalid' }) + }) + // The write runs outside the session's serialize, so even a refused Stop is answered at once. + expect(await within(invalid, 100)).toMatchObject({ settled: 'resolved' }) + const request = { envelope: envelope('agentSession.cancel', {}) } + expect(await within(host.cancel(CALLER, request), 2_000)).toMatchObject({ settled: 'resolved' }) + expect(await invalid).toMatchObject({ ok: false }) + expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true }) + expect( + host.collaboratorsForTests().sessions.get(SESSION)?.journal.submissions()[0]?.dispatchState + ).toBe('rejected') + expect( + (await rows()).filter( + (row) => row.body.kind === 'status' && row.body.text === STOP_NOTE_CANCELLATION_REQUESTED + ) + ).toHaveLength(1) + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS }) + expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending') }) - it('does not hold a Close behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig() - const picking = pick(host, 'grok-4.6') + it('does not hold a Close behind its write', async () => { + const { rig, host } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + await send(host, 'hello') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ settled: 'resolved' }) - expect(failed(await within(picking, 1_000))).toBe(true) + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS }) + expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) }) - it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { - const { rig, host, store, fence } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) - const picking = pick(host, 'grok-4.6') + it('re-establishes opening A after uncertain B without adopting B’s late reply', async () => { + const { rig, host, store, fence } = await openAttachedHostRig({ + optionWriteTimeoutMs: 100, + script: (agent) => agent.on('session/set_config_option', () => {}) + }) + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + const savedModel = store.getRecord(SESSION)?.options?.model + expect(savedModel).toBe('grok-4.6') + await send(host, 'hello') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) - expect(failed(await within(picking, 2_000))).toBe(true) + await waitFor(() => + expect( + host.collaboratorsForTests().sessions.get(SESSION)?.journal.submissions()[0]?.dispatchState + ).toBe('rejected') + ) + expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) // The chat's queue moved on: a later operation runs. expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') - const [frame] = framesOf(rig.child(), 'session/set_config_option') - const savedModel = store.getRecord(SESSION)?.options?.model - rig.child().agent.reply(frame!, { + const restore = rig.adapter.dispatch({ + sessionId: SESSION, + fence: fence(), + clientMessageId: 'restore-A', + executionOptions: { model: 'grok-4.7' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'A again' }] } + }) + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(2)) + const [old, current] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(current!, { configOptions: GROK_CONFIG_OPTIONS }) + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(3)) + rig.child().agent.reply(framesOf(rig.child(), 'session/set_config_option')[2]!, { + configOptions: GROK_CONFIG_OPTIONS + }) + await restore + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + rig.child().agent.reply(old!, { configOptions: GROK_CONFIG_OPTIONS.map((option) => option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option ) }) - await waitFor(async () => - expect( - (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current.model - ).toBe('grok-4.6') - ) + await rig.settle() + expect( + (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current.model + ).toBe('grok-4.7') expect((await host.readOptions(SESSION)).current.model).toBe(savedModel) expect(store.getRecord(SESSION)?.options?.model).toBe(savedModel) await host.close(SESSION, 'user-close') }) }) + +describe('a saved Grok pick', () => { + it.each(['Stop', 'Close'] as const)( + 'never contacts Grok or holds %s behind a setter', + async (ending) => { + const { rig, host, store } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) + expect(await within(pick(host, 'grok-4.6'), 1_000)).toMatchObject({ + settled: 'resolved', + value: { ok: true } + }) + expect(store.getRecord(SESSION)?.options?.model).toBe('grok-4.6') + expect(framesOf(rig.child(), 'session/set_config_option')).toEqual([]) + const ended = ending === 'Stop' ? stop(host) : host.close(SESSION, 'user-close') + expect(await within(ended, 2_000)).toMatchObject({ settled: 'resolved' }) + await host.close(SESSION, 'user-close') + } + ) +}) diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts index f4024117ec9..bae22410c7d 100644 --- a/src/main/acp/acp-structured-host-restore-failed.test.ts +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -120,14 +120,16 @@ async function openRestoreRig(code: number, message: string) { describe('a saved Grok session Grok cannot reopen', () => { it('continues the chat in a fresh session that the chain records and the next reopen loads', async () => { - const { host, store, fence, loads, warnings, exchange } = await openRestoreRig( + const { host, store, fence, loads, warnings, ready, exchange } = await openRestoreRig( -32603, 'session file is corrupt' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] @@ -160,6 +162,7 @@ describe('a saved Grok session Grok cannot reopen', () => { // The next reopen loads the fresh session, and says nothing new. await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(store.getRecord(SESSION)?.providerHandleChain).toHaveLength(3) expect(await warnings()).toHaveLength(1) @@ -169,14 +172,16 @@ describe('a saved Grok session Grok cannot reopen', () => { describe('a created Grok session Grok reports missing on the first reopen', () => { it('replaces it and says so once when the chat exchanged a turn on it', async () => { - const { host, store, fence, loads, warnings, exchange, messages } = await openRestoreRig( + const { host, store, fence, loads, warnings, ready, exchange, messages } = await openRestoreRig( -32002, 'Resource not found' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await exchange('remember 42', 'noted', PROVIDER_SESSION) await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] @@ -195,19 +200,22 @@ describe('a created Grok session Grok reports missing on the first reopen', () = // The next reopen loads the fresh session, and says nothing new. await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') }) it('supersedes it silently when nothing was exchanged on it', async () => { - const { host, store, fence, loads, warnings } = await openRestoreRig( + const { host, store, fence, loads, warnings, ready } = await openRestoreRig( -32002, 'Resource not found' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] @@ -224,13 +232,14 @@ describe('a created Grok session Grok reports missing on the first reopen', () = }) }) -describe('the warning row of a replacement whose attach failed', () => { - async function replaceThenFailAttach() { +describe('a replacement after its earlier attach failed to open the journal', () => { + async function failAttachBeforeReplacement() { const opened = await openRestoreRig(-32603, 'session file is corrupt') - const { host, store, fence, warnings } = opened + const { host, store, fence, warnings, ready } = opened expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') - // The journal's open fails after the fresh session's link is durable, before its row is written. + // The journal must open before a spawned child's background handshake proves a new link. vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( new Error('journal path unavailable') ) @@ -240,32 +249,37 @@ describe('the warning row of a replacement whose attach failed', () => { expect( agentSessionProviderHandleChainHead(store.getRecord(SESSION)?.providerHandleChain ?? []) ).toMatchObject({ - handle: { nativeId: FRESH }, - replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + handle: { nativeId: PROVIDER_SESSION } }) expect(await warnings()).toEqual([]) return opened } - it('is written by the next start that loads the fresh session, and only once', async () => { - const { host, fence, loads, warnings } = await replaceThenFailAttach() + it('writes the warning when the next start replaces the old session, and only once', async () => { + const { host, fence, loads, warnings, ready } = await failAttachBeforeReplacement() expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) - expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + await ready() + expect(loads).toEqual([PROVIDER_SESSION]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) - expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH]) + await ready() + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') }) it('is written when Grok never saved the fresh session and a newer one supersedes it', async () => { - const { host, store, fence, loads, lost, warnings } = await replaceThenFailAttach() + const { host, store, fence, loads, lost, warnings, ready } = await failAttachBeforeReplacement() + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() + await host.close(SESSION, 'user-close') lost.set(FRESH, { code: -32002, message: 'Resource not found' }) expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] expect(chain).toHaveLength(2) @@ -277,6 +291,7 @@ describe('the warning row of a replacement whose attach failed', () => { expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') @@ -285,23 +300,27 @@ describe('the warning row of a replacement whose attach failed', () => { describe('a replacement Grok never saved, superseded after its row was written', () => { it('keeps the one row: the conversation lost is still the first one', async () => { - const { host, fence, loads, lost, warnings } = await openRestoreRig( + const { host, fence, loads, lost, warnings, ready } = await openRestoreRig( -32603, 'session file is corrupt' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') lost.set(FRESH, { code: -32002, message: 'Resource not found' }) expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(await warnings()).toHaveLength(1) // The newer session carries the same loss, so reopening it says nothing new. await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 216a495e1bc..d92f99d2dd6 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -35,7 +35,7 @@ function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): aborts.begin = (sessionId) => { const started = begin(sessionId) return { - signal: started.signal, + ...started, end: () => { if (!fired) { fired = true @@ -70,7 +70,7 @@ describe('a Grok start the host aborts', () => { await rig.frame('initialize') // The start has no bound of its own: the quit is what ends it. expect(await within(host.flushAllStreamedEvents({ trigger: 'quit' }), 2_000)).not.toBe(STALLED) - expect((await attaching).ok).toBe(false) + expect((await attaching).ok).toBe(true) expect(rig.child().exited).toBe(true) await rig.adapter.closeAll() }) @@ -180,6 +180,7 @@ async function openClosedResumableChat(options: { hangsHandshake?: number } = {} deps: { resolveLaunch: launch(() => resumed) } }) expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await rig.ready() resumed = true await rig.host.close(SESSION, 'user-close') return { ...rig, children: () => children } diff --git a/src/main/acp/acp-structured-host-start-failure.test.ts b/src/main/acp/acp-structured-host-start-failure.test.ts new file mode 100644 index 00000000000..74fac5c26d6 --- /dev/null +++ b/src/main/acp/acp-structured-host-start-failure.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { waitFor } from './acp-structured-adapter.test-support' +import { attachParams, openHostRig, send } from './acp-structured-host.test-support' + +afterEach(closeProviderTimelineRigs) + +it('tells queued messages the agent is signed out when its published startup fails authentication', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('session/new', () => {}) + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + const request = await rig.frame('session/new') + const messageId = await send(host, 'held during sign-in') + expect(rig.sent('session/prompt')).toEqual([]) + rig.child().agent.fail(request, -32000, 'Authentication required') + await waitFor(async () => { + const snapshot = await host.journalSnapshot(SESSION) + const submission = snapshot.submissions.find((entry) => entry.clientMessageId === messageId) + expect(submission?.handedOverAt).toBeUndefined() + expect(submission).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'notSignedIn' }, + reason: expect.stringMatching(/^Sign in to Grok/) + }) + }) + expect(rig.child().exited).toBe(true) + expect(rig.sent('session/prompt')).toEqual([]) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index 75b474314ac..ce003faa81e 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -30,6 +30,7 @@ import { openAcpAdapterRig, PROVIDER_SESSION, replyChunk, + waitFor, type FakeAcpChild } from './acp-structured-adapter.test-support' import { @@ -128,12 +129,17 @@ export async function openHostRig( hosted.host = host replaceHostTestState({ store, host }) const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + const ready = () => + waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) const messages = async () => (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items .filter((row) => row.body.kind === 'message') .map((row) => messageText(row.body)) /** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */ const exchange = async (text: string, end: boolean) => { + await ready() const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal await journal.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { fence: fence(), @@ -153,7 +159,7 @@ export async function openHostRig( await rig.settle() await host.flushStreamedEvents(SESSION) } - return { rig, host, store, journalDatabase, fence, messages, exchange } + return { rig, host, store, journalDatabase, fence, messages, exchange, ready } } /** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ @@ -189,24 +195,30 @@ export const framesOf = (child: FakeAcpChild, method: string) => /** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ export async function openAttachedHostRig( - deps: Partial = {}, + deps: Partial & { + script?: (agent: AcpScriptedAgent) => void + } = {}, statusSink?: StructuredAgentSessionStatusSink, modelCatalog?: AgentModelCatalogService ) { + const { script, ...adapterDeps } = deps const count = { loads: 0 } let resumed = false const rig = await openHostRig({ statusSink, ...(modelCatalog ? { modelCatalog } : {}), initialize: RESUMES, - script: (agent) => + script: (agent) => { agent.on('session/load', (frame) => { count.loads += 1 agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) - }), - deps: { resolveLaunch: launch(() => resumed), ...deps } + }) + script?.(agent) + }, + deps: { resolveLaunch: launch(() => resumed), ...adapterDeps } }) expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await rig.ready() resumed = true const rows = async () => { await rig.host.flushStreamedEvents(SESSION) diff --git a/src/main/acp/acp-structured-missing-model.test.ts b/src/main/acp/acp-structured-missing-model.test.ts index 738ba92bf33..15cca255992 100644 --- a/src/main/acp/acp-structured-missing-model.test.ts +++ b/src/main/acp/acp-structured-missing-model.test.ts @@ -2,7 +2,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { replaceAgentSessionRecordOptions } from '../runtime/agent-session-record-options' import { agentSessionStoreDraftRowWrites } from '../runtime/agent-session-store-draft' import type { AgentSessionStoreState } from '../runtime/agent-session-store-state' @@ -27,7 +26,7 @@ const modelOption: SessionConfigOption = { options: [{ value: 'reported-model', name: 'Reported Model' }] } -async function writtenOptions(provider: string, reader: AcpStructuredOptions) { +function writtenOptions(provider: string, reader: AcpStructuredOptions) { const fixture = agentSessionRecordFixture() const record = { ...fixture, @@ -45,14 +44,7 @@ async function writtenOptions(provider: string, reader: AcpStructuredOptions) { unreadableRecords: new Map(), sessionTabs: null } - const options = await readNativeSessionOptions({ - adapter: { readOptions: async () => reader.read() }, - sessionId: record.sessionId, - fence: record.lease.runtimeFence - }) - if (!options) { - throw new Error('The live reader must report its options') - } + const options = reader.reported() const changed = replaceAgentSessionRecordOptions(record, { sessionId: record.sessionId, fence: record.lease.runtimeFence, @@ -71,7 +63,7 @@ async function writtenOptions(provider: string, reader: AcpStructuredOptions) { } describe('ACP sessions without a reported model', () => { - it('writes the captured Windows OMP startup through the live reader and shared normalizer', async () => { + it('writes the captured Windows OMP startup through the provider reader', async () => { const reader = new AcpStructuredOptions() reader.adoptSession(response) expect(reader.read()).toEqual({ @@ -79,14 +71,14 @@ describe('ACP sessions without a reported model', () => { current: { effort: 'off', confirmed: ['effort'] } }) expect(reader.reported()).toEqual({ effort: 'off' }) - expect(await writtenOptions('omp', reader)).toEqual({ effort: 'off' }) + expect(writtenOptions('omp', reader)).toEqual({ effort: 'off' }) }) it.each(['grok', 'opencode'])('writes missing-model options for %s', async (provider) => { const reader = new AcpStructuredOptions() reader.adoptSession({}) expect(reader.read().current).not.toHaveProperty('model') - expect(await writtenOptions(provider, reader)).toEqual({}) + expect(writtenOptions(provider, reader)).toEqual({}) }) it.each(['config', 'legacy'] as const)('does not expose an empty %s model value', (source) => { @@ -103,13 +95,13 @@ describe('ACP sessions without a reported model', () => { it('records a model reported after a startup with no model', async () => { const reader = new AcpStructuredOptions() reader.adoptSession(response) - expect(await writtenOptions('omp', reader)).toEqual({ effort: 'off' }) + expect(writtenOptions('omp', reader)).toEqual({ effort: 'off' }) reader.adoptConfigOptions([...(response.configOptions ?? []), modelOption]) expect(reader.read().current).toEqual({ model: 'reported-model', effort: 'off', confirmed: ['model', 'effort'] }) - expect(await writtenOptions('omp', reader)).toEqual({ model: 'reported-model', effort: 'off' }) + expect(writtenOptions('omp', reader)).toEqual({ model: 'reported-model', effort: 'off' }) }) }) diff --git a/src/main/acp/acp-structured-options.test.ts b/src/main/acp/acp-structured-options.test.ts index b348d0fdba9..917c9bf690c 100644 --- a/src/main/acp/acp-structured-options.test.ts +++ b/src/main/acp/acp-structured-options.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { AcpStructuredOptions } from './acp-structured-options' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' const configOptions = [ @@ -121,11 +121,6 @@ describe('ACP session options', () => { }) describe('what a new session with no pick says about the configured default', () => { - const connection = { - setConfigOption: async () => ({ configOptions }), - setModel: async () => ({}) - } - it('is the model and effort the agent chose for itself', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }, 'new') @@ -138,17 +133,17 @@ describe('what a new session with no pick says about the configured default', () expect(options.configuredDefault()).toBeUndefined() }) - it('says nothing once the chat holds a model pick, even one the session already runs', async () => { + it('says nothing once the chat holds a model pick, even one the session already runs', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }, 'new') - await restoreAcpSessionOptions(connection, options, { model: 'model-b' }) + options.notePick('model') expect(options.configuredDefault()).toBeUndefined() }) - it('keeps the model but not an effort the chat picked', async () => { + it('keeps the model but not an effort the chat picked', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }, 'new') - await restoreAcpSessionOptions(connection, options, { effort: 'low' }) + options.notePick('effort') expect(options.configuredDefault()).toEqual({ modelId: 'model-b' }) }) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index cfc3ca091c5..39905ebef9f 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -2,14 +2,14 @@ // effort), the older model list, and its `/` commands — as Orca's option and command surfaces. // Kept as the agent last reported it; nothing here is guessed or listed by Orca itself. +import { abortSignalReason } from '../../shared/abort-signal-reason' + import type { AgentSessionModelOption, AgentSessionOptionChoice, AgentSessionOptionsResult, AgentSessionSlashCommand } from '../../shared/agent-session-wire' -import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' -import { AcpRpcError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpLaunchSpec } from './acp-launch-specs' import type { AcpDialect } from './acp-dialects/acp-dialect' @@ -65,7 +65,10 @@ export class AcpStructuredOptions { private commands: AgentSessionSlashCommand[] | undefined // A loaded session keeps the model it ran; only a new one resolves the agent's own config. private resolvesConfig = false - private readonly picked = new Set() + readonly picked = new Set() + private initialChoice: Readonly> = {} + currentProof = new Set() + writeRevision = 0 /** The session state a new, load or resume response reported. */ adoptSession( @@ -78,6 +81,13 @@ export class AcpStructuredOptions { this.configOptions = response.configOptions ?? [] this.models = response.models ?? null this.resolvesConfig = origin === 'new' + this.initialChoice = this.reported() + this.currentProof = new Set(Object.keys(this.initialChoice)) + } + + /** The native selection before any message changed this child. */ + messageDefault(): Readonly> { + return this.initialChoice } /** Orca sent `key` to the session, or the chat's saved options hold a value for it. */ @@ -238,30 +248,50 @@ export async function readAcpLiveOptions( return { ...withLiveCatalogListing(session.options.read()), acceptsImages } } -/** Sends a pick and adopts the agent's answer, even one that lands after the wait gave up: that is - * still what the agent runs. The wait fails at `timeoutMs`, or once `signal` aborts. */ +/** Only the current, still-awaited write may establish reusable option proof. */ export function writeAcpSessionOption( connection: Pick, options: AcpStructuredOptions, write: AcpOptionWrite, - bound: { agent: string; timeoutMs: number; signal?: AbortSignal } + bound: { agent: string; timeoutMs: number; signal?: AbortSignal; key: string } ): Promise { const { agent, timeoutMs, signal } = bound if (signal?.aborted) { - return Promise.reject(new Error(`${agent} option write abandoned before it was sent`)) + return Promise.reject(abortSignalReason(signal)) + } + const revision = ++options.writeRevision + options.currentProof.delete(bound.key) + if (bound.key === 'model') { + options.currentProof.delete('effort') + } + const adopt = (apply: () => void): void => { + if (options.writeRevision !== revision) { + throw new Error(`${agent} option write was superseded`) + } + apply() + options.currentProof.add(bound.key) } const applied = write.method === 'config' ? connection .setConfigOption(write.configId, write.value) - .then((result) => options.adoptConfigOptions(result.configOptions)) - : connection.setModel(write.modelId).then(() => options.adoptModel(write.modelId)) + .then((result) => adopt(() => options.adoptConfigOptions(result.configOptions))) + : connection + .setModel(write.modelId) + .then(() => adopt(() => options.adoptModel(write.modelId))) return new Promise((resolve, reject) => { const fail = (error: Error): void => { + if (options.writeRevision === revision) { + options.writeRevision += 1 + } settle() reject(error) } - const onAbort = (): void => fail(new Error(`${agent} option write abandoned`)) + const onAbort = (): void => { + if (signal) { + fail(abortSignalReason(signal)) + } + } const timer = setTimeout( () => fail(new Error(`${agent} did not answer an option write within ${timeoutMs}ms`)), timeoutMs @@ -277,41 +307,3 @@ export function writeAcpSessionOption( }, fail) }) } - -/** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and - * reported, never retried. */ -export async function restoreAcpSessionOptions( - connection: Pick, - options: AcpStructuredOptions, - saved: Readonly> | undefined -): Promise { - const skipped: string[] = [] - for (const [key, value] of Object.entries(saved ?? {})) { - if (!isAcpStructuredOptionKey(key)) { - continue - } - options.notePick(key) - if (options.reported()[key] === value) { - continue - } - const write = options.write(key, value) - try { - if (write?.method === 'config') { - options.adoptConfigOptions( - (await connection.setConfigOption(write.configId, value)).configOptions - ) - } else if (write?.method === 'model') { - await connection.setModel(write.modelId) - options.adoptModel(write.modelId) - } else { - skipped.push(key) - } - } catch (error) { - if (!(error instanceof AcpRpcError)) { - throw error - } - skipped.push(key) - } - } - return skipped -} diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts new file mode 100644 index 00000000000..1ff02a26492 --- /dev/null +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -0,0 +1,203 @@ +import { z } from 'zod' +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + waitFor +} from './acp-structured-adapter.test-support' +import type { FakeFrame } from './acp-scripted-agent.test-support' + +afterEach(closeProviderTimelineRigs) +const configParams = z.object({ configId: z.string(), value: z.string() }) + +describe('ACP child published before its handshake', () => { + it('opens without writing saved message choices to the provider', async () => { + let answer: (() => void) | undefined + const writes: string[] = [] + let configOptions = GROK_CONFIG_OPTIONS + const rig = await openAcpAdapterRig({ + script: (agent) => { + agent.on('session/new', (frame) => { + answer = () => agent.reply(frame, { sessionId: PROVIDER_SESSION, configOptions }) + }) + agent.on('session/set_config_option', (frame) => { + const { configId: key, value } = configParams.parse(frame.params) + writes.push(key) + configOptions = configOptions.map((option) => + option.id === key ? { ...option, currentValue: value } : option + ) + agent.reply(frame, { configOptions }) + }) + } + }) + const acquired = await rig.acquire({ + waitForStart: false, + options: { effort: 'low', model: 'grok-4.6' } + }) + expect(acquired.process.pid).toBe(4242) + if (!acquired.acquisitionGeneration) { + throw new Error('missing acquisition generation') + } + expect(acquired.link).toBeUndefined() + expect(rig.adapter.holdsLiveProviderProcess(SESSION, acquired.acquisitionGeneration)).toBe(true) + expect(rig.adapter.holdsDispatch(SESSION)).toBe(false) + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + const opening = rig.adapter.open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) + await rig.frame('session/new') + expect(rig.lifecycle).toEqual([]) + answer?.() + expect(await opening).toMatchObject({ + link: { handle: { nativeId: PROVIDER_SESSION }, origin: 'created' }, + reportedOptions: { model: 'grok-4.7', effort: 'high' } + }) + expect(writes).toEqual([]) + rig.child().agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'config_option_update', configOptions: GROK_CONFIG_OPTIONS } + }) + await waitFor(() => + expect(rig.lifecycle.map((event) => event.type)).toEqual(['options-reported']) + ) + }) + + it.each(['closeSession', 'disposeSession', 'forceCloseSession', 'releaseAcquisition'] as const)( + 'lets %s stop the starting process without a late start', + async (method) => { + let frame: FakeFrame | undefined + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', (request) => { + frame = request + }) + }) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) + await rig.frame('session/new') + if (!acquired.acquisitionGeneration) { + throw new Error('missing acquisition generation') + } + const closed = + method === 'releaseAcquisition' + ? rig.adapter[method]({ sessionId: SESSION }) + : rig.adapter[method](SESSION) + expect(await closed).toBe(true) + expect(await opening).toBeInstanceOf(Error) + if (frame) { + rig.child().agent.reply(frame, { sessionId: PROVIDER_SESSION }) + } + await rig.settle() + expect(rig.lifecycle.map((event) => event.type)).toEqual(['ended']) + expect(rig.adapter.holdsLiveProviderProcess(SESSION, acquired.acquisitionGeneration)).toBe( + false + ) + } + ) + + it.each([true, false])( + 'reports a startup exit with initialize answered = %s', + async (answered) => { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on(answered ? 'session/new' : 'initialize', () => {}) + }) + await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: 'gen-acp', + signal: new AbortController().signal + }) + .catch((error: unknown) => error) + await rig.frame(answered ? 'session/new' : 'initialize') + rig.child().stderr = 'provider config is invalid' + rig.child().exit() + expect(await opening).toBeInstanceOf(Error) + expect(rig.ended).toMatchObject([ + { failure: { detail: { text: 'provider config is invalid' } } } + ]) + expect(rig.ended[0]?.startupUnanswered).toBeUndefined() + } + ) + + it('reports signed-out startup as a notSignedIn fact and proves the process ended', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const acquired = await rig.acquire({ waitForStart: false }) + await expect( + rig.adapter.open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ reason: 'notSignedIn' }) + expect(rig.ended).toEqual([]) + await rig.adapter.closeSession(SESSION) + expect(rig.child().exited).toBe(true) + }) + + it('waits until message input to try a saved choice the provider will refuse', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/set_config_option', (frame) => + agent.fail(frame, -32602, 'model unavailable') + ) + }) + await rig.acquire({ options: { model: 'retired-model' } }) + expect(rig.started()).toMatchObject({ + reportedOptions: { model: 'grok-4.7', effort: 'high' } + }) + expect(rig.sent('session/set_config_option')).toEqual([]) + await expect( + rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'refused-choice', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + executionOptions: { model: 'retired-model' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(rig.sent('session/set_config_option')).toHaveLength(1) + expect(rig.sent('session/prompt')).toEqual([]) + expect(rig.child().exited).toBe(false) + }) + + it('reports the session it loaded as the resumed link', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-session', + key: 'saved-key', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + } + }) + const acquired = await rig.acquire() + expect(acquired.link).toBeUndefined() + expect(rig.started().link).toMatchObject({ + handle: { nativeId: 'saved-session' }, + origin: 'resumed' + }) + expect(rig.sent('session/new')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 5161d42cecf..28c7a9e0f9b 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -5,7 +5,6 @@ import { providerTurnId, SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { StructuredAgentSessionTaskQueue } from '../native-chat/agent-session-wire/structured-agent-session-task-queue' import { agentSessionLaunchFolderMissing } from '../runtime/agent-session-launch-directory' import { tick } from './acp-scripted-agent.test-support' import { @@ -138,7 +137,6 @@ describe('ACP Stop of a turn Grok began itself', () => { } }) ) - expect(rig.adapter.stopEndsSession()).toBe(true) await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ cancelled: true }) @@ -173,7 +171,7 @@ describe('ACP connection loss', () => { rig.child().agent.stdin.destroy() await rig.settle() await waitFor(() => - expect(rig.lifecycle).toMatchObject([ + expect(rig.ended).toMatchObject([ { type: 'ended', cause: 'unexpected-exit', acquisitionGeneration: 'gen-acp' } ]) ) @@ -195,10 +193,10 @@ describe('ACP connection loss', () => { rig.child().stderr = 'panic: out of memory' rig.child().agent.stdout.end() await rig.settle() - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) rig.child().exit() - await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) - expect(rig.lifecycle[0]).toMatchObject({ + await waitFor(() => expect(rig.ended).toHaveLength(1)) + expect(rig.ended[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit', reason: 'grok ACP agent exited: panic: out of memory', @@ -214,7 +212,7 @@ describe('ACP connection loss', () => { child.stderr = 'panic: late' child.agent.close() await rig.settle() - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) // Never left Orca, so it is not recorded as unconfirmed. expect( await rig.adapter.dispatch({ @@ -233,8 +231,8 @@ describe('ACP connection loss', () => { name: 'AgentSessionAcquisitionExitUnprovenError' }) child.exit() - await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) - expect(rig.lifecycle[0]).toMatchObject({ + await waitFor(() => expect(rig.ended).toHaveLength(1)) + expect(rig.ended[0]).toMatchObject({ cause: 'unexpected-exit', reason: 'grok ACP agent exited: panic: late', failure: { kind: 'providerExited', detail: { text: 'panic: late' } } @@ -279,7 +277,7 @@ describe('ACP connection loss', () => { await rig.settle() expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['garbled', 'next']) expect(rig.child().closes).toBe(0) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) }) it('leaves Grok running when it ends its stdout but can still be written to', async () => { @@ -293,9 +291,9 @@ describe('ACP connection loss', () => { await rig.settle() expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) expect(rig.child().closes).toBe(0) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) - expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }) }) @@ -339,77 +337,84 @@ describe('ACP refusals', () => { }) describe('ACP startup that never answers', () => { - it('lets a close stop the child while the handshake is unanswered', async () => { + it('lets a close stop the published child while initialize is unanswered', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) - const queue = new StructuredAgentSessionTaskQueue() - const start = new AbortController() - const acquiring = queue.serialize(SESSION, () => rig.acquire({ signal: start.signal })) - const failed = acquiring.catch((error: unknown) => error) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) await rig.frame('initialize') - // What the host's close does outside the queue, then its queued stop. - start.abort() - const closing = queue.serialize(SESSION, () => rig.adapter.closeSession(SESSION)) - expect(rig.child().closes).toBe(1) - expect(await failed).toMatchObject({ message: 'Grok was closed while starting' }) - await expect(closing).resolves.toBe(true) - expect(rig.lifecycle).toEqual([]) + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + expect(await opening).toBeInstanceOf(Error) + expect(rig.child().exited).toBe(true) + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) }) - it('leaves the child of a start that returned alone when its signal aborts later', async () => { + it('leaves a published child alone when the acquisition signal aborts later', async () => { const rig = await openAcpAdapterRig() const start = new AbortController() await rig.acquire({ signal: start.signal }) - // The host's close, landing while it still commits the attach, stops the child its own way. start.abort(new Error('closed while starting')) await rig.settle() expect(rig.child().closes).toBe(0) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) }) - it('lets a handshake Grok never answers run on until the start is aborted', async () => { + it('does not time out a published handshake, and closes it through the session', async () => { + const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}) }) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) + await rig.frame('session/new') vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) try { - const rig = await openAcpAdapterRig({ - script: (agent) => agent.on('session/new', () => {}) - }) - const start = new AbortController() - const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) - let settled = false - void failed.then(() => { - settled = true - }) - await rig.settle() - vi.advanceTimersByTime(30 * 60_000) - await rig.settle() - expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) - start.abort() - expect(await failed).toBeInstanceOf(Error) - expect(rig.child().closes).toBeGreaterThan(0) + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + expect(await opening).toBeInstanceOf(Error) } finally { vi.useRealTimers() } }) - it('keeps the child of a failed start until its exit is proven, and starts no other meanwhile', async () => { - const rig = await openAcpAdapterRig({ - script: (agent) => agent.on('initialize', () => {}) - }) - const start = new AbortController() - const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) - await rig.frame('initialize') + it('keeps a failed startup child until its exit is proven, and spawns no other meanwhile', async () => { + const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) + const frame = await rig.frame('initialize') const child = rig.child() child.proveClose = vi.fn(async () => false) - start.abort() - expect(await failed).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) - // Every later stop asks the child again, and none answers for it. + child.agent.fail(frame, -32603, 'provider failed to initialize') + expect(await opening).toBeInstanceOf(Error) + expect(child.closes).toBe(0) + expect(rig.ended).toEqual([]) expect(await rig.adapter.closeSession(SESSION)).toBe(false) await expect(rig.adapter.closeAll()).rejects.toThrow('could not be proven stopped') - expect(await rig.acquire().catch((error: unknown) => error)).toMatchObject({ + await expect(rig.acquire()).rejects.toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) child.exit() + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(await rig.adapter.closeSession(SESSION)).toBe(true) }) @@ -464,7 +469,7 @@ describe('ACP session release', () => { const rig = await openAcpAdapterRig() await expect(rig.adapter.releaseAcquisition({ sessionId: SESSION })).resolves.toBe(true) expect(rig.spawned).toEqual([]) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) }) it('keeps nothing of a chat once its child is proven closed', async () => { @@ -481,7 +486,7 @@ describe('ACP session release', () => { const rig = await openAcpAdapterRig() await rig.acquire() rig.child().exit() - await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + await waitFor(() => expect(rig.ended).toHaveLength(1)) expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) }) diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index ea48b3e5d74..8e1f8455c63 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -7,7 +7,6 @@ import { messageText, SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' 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 type { AcpScriptedAgent } from './acp-scripted-agent.test-support' @@ -207,7 +206,8 @@ it("starts in the chat's own permission mode, asks when none is named, and fails }) const [chosen, unnamed] = [await open(), await open()] await chosen.acquire({ permissionMode: 'accept-edits' }) - await expect(unnamed.acquire()).rejects.toThrow('Permission mode could not be confirmed') + await unnamed.acquire() + expect(unnamed.openFailure()).toEqual(new Error('Permission mode could not be confirmed')) const sent = [chosen, unnamed].map((rig) => rig.sent('session/set_config_option')[0]?.params) expect(sent).toMatchObject([ { configId: 'approval_mode', value: 'auto_edit' }, @@ -333,8 +333,8 @@ describe('reattaching a Grok chat the journal holds', () => { script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Resource not found')) }) - const acquired = await rig.acquire() - expect(acquired.link).toMatchObject({ + await rig.acquire() + expect(rig.started().link).toMatchObject({ origin: 'created', handle: { nativeId: PROVIDER_SESSION }, supersedesKey: 'acp:grok:never-saved' @@ -365,13 +365,13 @@ describe('reattaching a Grok chat the journal holds', () => { initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message)) }) - const acquired = await rig.acquire() - expect(acquired.link).toMatchObject({ + await rig.acquire() + expect(rig.started().link).toMatchObject({ origin: 'created', handle: { nativeId: PROVIDER_SESSION }, replaces: { key: 'acp-key-saved-1', reason: 'restore-failed', replacedAt: 5_000 } }) - expect(acquired.link?.supersedesKey).toBeUndefined() + expect(rig.started().link?.supersedesKey).toBeUndefined() expect(rig.sent('session/new')).toHaveLength(1) const rows = await notRestoredRows(rig) expect(rows).toHaveLength(1) @@ -396,9 +396,8 @@ describe('reattaching a Grok chat the journal holds', () => { script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32000, 'Authentication required')) }) - const failure = await rig.acquire().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) - expect(failure).toMatchObject({ reason: 'notSignedIn' }) + await rig.acquire() + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) expect(rig.sent('session/new')).toEqual([]) }) @@ -418,13 +417,12 @@ describe('reattaching a Grok chat the journal holds', () => { deps: { logger }, script: (agent) => agent.on('session/load', (frame) => { - start.abort() + void rig.adapter.closeSession(SESSION) agent.fail(frame, -32603, 'session file is corrupt') }) }) - expect( - await rig.acquire({ signal: start.signal }).catch((error: unknown) => error) - ).toBeInstanceOf(Error) + await rig.acquire({ signal: start.signal }) + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(rig.sent('session/new')).toEqual([]) expect(await notRestoredRows(rig)).toEqual([]) // Nothing was replaced, so nothing says it was. diff --git a/src/main/acp/acp-structured-session-adapter-teardown.test.ts b/src/main/acp/acp-structured-session-adapter-teardown.test.ts index 9d9e3022826..fab34e911cd 100644 --- a/src/main/acp/acp-structured-session-adapter-teardown.test.ts +++ b/src/main/acp/acp-structured-session-adapter-teardown.test.ts @@ -62,7 +62,7 @@ describe('ACP teardown of a running turn', () => { const atClose = watchClose(rig.child(), turn) await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) expect(atClose).toEqual({ cancels: 1, turnEnded: true }) - expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }) it('a dispose ends the child at the grace when the agent never ends its turn', async () => { diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 291dfe000d6..b470ab562c3 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -4,11 +4,7 @@ import { closeProviderTimelineRigs, SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { - AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionRefusal, - AgentSessionAcquisitionRootExitObservedError -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect' import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' @@ -52,7 +48,7 @@ describe('ACP structured session adapter: acquire', () => { const acquired = await rig.acquire() expect(rig.spawned).toEqual(['spawn', 'onSpawned', 'initialize']) expect(acquired.process).toMatchObject({ pid: PID, spawnToken: 'spawn-1', hostId: 'local' }) - expect(acquired.link).toMatchObject({ + expect(rig.started().link).toMatchObject({ handle: { transport: 'acp', agent: 'grok', nativeId: PROVIDER_SESSION }, origin: 'created', mintedAtFence: 1 @@ -113,8 +109,8 @@ describe('ACP structured session adapter: acquire', () => { script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) }) - const acquired = await rig.acquire({ fence: 2 }) - expect(acquired.link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) + await rig.acquire({ fence: 2 }) + expect(rig.started().link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) }) it('refuses a signed-out agent through the existing start-failure surface and stops its child', async () => { @@ -122,9 +118,9 @@ describe('ACP structured session adapter: acquire', () => { script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) }) - const failure = await rig.acquire().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) - expect(failure).toMatchObject({ reason: 'notSignedIn' }) + await rig.acquire() + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(rig.child().closes).toBe(1) }) }) @@ -138,10 +134,10 @@ it('reports an agent that exits while starting with its own last words', async ( child.exit() }) }) - const failure = await rig.acquire().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) - expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) - expect(rig.lifecycle).toEqual([]) + await rig.acquire() + expect(rig.ended).toMatchObject([ + { failure: { detail: { text: 'grok: config.toml is invalid' } } } + ]) }) it('reads those last words when the agent ends its stdout before its exit is seen', async () => { @@ -155,12 +151,35 @@ it('reads those last words when the agent ends its stdout before its exit is see setTimeout(() => child.exit(), 5) }) }) - const failure = await rig.acquire().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) - expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) + await rig.acquire() + expect(rig.ended).toMatchObject([ + { failure: { detail: { text: 'grok: config.toml is invalid' } } } + ]) }) describe('ACP structured session adapter: turns', () => { + it('applies a sent message’s model and effort before its prompt', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'chosen-send', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + executionOptions: { model: 'grok-4.6', effort: 'low' } + }) + await rig.frame('session/prompt') + const methods = rig.child().agent.frames.map((frame) => frame.method) + expect(methods.slice(-3)).toEqual([ + 'session/set_config_option', + 'session/set_config_option', + 'session/prompt' + ]) + await expect(rig.adapter.readOptions({ sessionId: SESSION, fence: 1 })).resolves.toMatchObject({ + current: { model: 'grok-4.6', effort: 'low' } + }) + }) + it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => { const rig = await openAcpAdapterRig({ deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } } @@ -434,18 +453,18 @@ describe('ACP structured session adapter: close and exit', () => { await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf( AgentSessionAcquisitionRootExitObservedError ) - expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }) it('closes with proof and reports a requested close', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) - expect(rig.lifecycle).toMatchObject([ + expect(rig.ended).toMatchObject([ { type: 'ended', sessionId: SESSION, cause: 'requested-close', fence: 1 } ]) await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) - expect(rig.lifecycle).toHaveLength(1) + expect(rig.ended).toHaveLength(1) }) it('ends the session on an unexpected exit: held sends rejected, the running one unknown', async () => { @@ -457,7 +476,7 @@ describe('ACP structured session adapter: close and exit', () => { rig.child().stderr = 'grok: crashed' rig.child().exit() await waitFor(() => - expect(rig.lifecycle).toMatchObject([ + expect(rig.ended).toMatchObject([ { type: 'ended', cause: 'unexpected-exit', diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 8c62c52b482..b23068f8ba7 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -3,12 +3,12 @@ // them; compaction is the agent's own `/compact` prompt, for an agent whose launch spec offers it. import { randomUUID } from 'node:crypto' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { withProviderDiagnostic } from '../../shared/agent-session-failure' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionRootExitObservedError, AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, isAgentSessionPreSpawnError, type AgentSessionAcquisition, @@ -18,20 +18,15 @@ import { type StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' -import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' -import { - closeAcpSessionJournal, - endAcpStructuredSession, - rejectedAcpDispatch, - type AcpStructuredSession -} from './acp-structured-session' -import { - ProviderAcquisitionStarts, - type ProviderStartAttempt -} from '../provider-process/provider-acquisition-starts' +import { acpAgentName } from './acp-structured-agent-definitions' +import { acquireAcpStructuredSession } from './acp-structured-acquire' +import { rejectedAcpDispatch, type AcpStructuredSession } from './acp-structured-session' +import type { ProviderStartAttempt } from '../provider-process/provider-acquisition-starts' import { waitForAcpExit, type AcpStructuredConnection } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' -import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' +import { applyAcpMessageOptions } from './acp-message-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { awaitAcpTurnEnd, interruptAcpTurn } from './acp-structured-stop' import { acpDispatchPrompt } from './acp-prompt-content' import { ACP_OPTION_WRITE_TIMEOUT_MS, @@ -41,16 +36,16 @@ import { import { readAcpLiveOptions, writeAcpSessionOption } from './acp-structured-options' import { readAcpRecoveryHistory } from './acp-recovery-history' import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop' +import { acpStartupFailure, isAcpReadySession } from './acp-structured-child' +import { AcpStructuredChildren } from './acp-structured-child-lifecycle' import { AcpConversationCopies } from './acp-conversation-copies' import { supportsAcpStructuredLocation } from './acp-structured-location-support' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { - /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ - private readonly sessions = new Map() - private readonly starts = new ProviderAcquisitionStarts() + private readonly children: AcpStructuredChildren private readonly copies: AcpConversationCopies - constructor(private readonly deps: AcpStructuredSessionAdapterDeps) { + this.children = new AcpStructuredChildren(deps) this.copies = new AcpConversationCopies(deps) } @@ -71,9 +66,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte async acquire(input: StructuredAgentSessionAcquireInput): Promise { const sessionId = input.identity.sessionId const gate = this.deps.spec.launchGate?.enter(input.signal) - const attempt = this.starts.begin(gate?.signal ?? input.signal) + const attempt = this.children.starts.begin(gate?.signal ?? input.signal) try { - if (!(await this.stop(sessionId))) { + if (!(await this.children.stop(sessionId))) { throw new AgentSessionAcquisitionExitUnprovenError( new Error( `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` @@ -82,7 +77,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } return await this.start({ ...input, signal: attempt.signal }, attempt) } finally { - this.starts.end(attempt) + this.children.starts.end(attempt) gate?.leave() } } @@ -100,25 +95,24 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte generation, abandoned: () => attempt.signal.aborted, track: (connection) => { - this.starts.track(attempt, connection) + this.children.starts.track(attempt, connection) this.deps.spec.launchGate?.retain(connection) }, onExit: (session) => { - if (session && this.sessions.get(sessionId) === session) { - this.finish(session, this.now()) + if (session && this.children.sessions.get(sessionId) === session) { + this.children.finish(session, this.now()) } }, - onConnectionLost: (session, error) => this.connectionLost(session, error), + onConnectionLost: (session, error) => this.children.connectionLost(session, error), onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), forceClose: (id) => void this.forceCloseSession(id) }) if (attempt.signal.aborted) { - session.lane.dispose() + session.dispose() throw new Error('closed while starting') } - this.sessions.set(sessionId, session) - // Its saved picks restored: what it runs now is what its start resolved. - return { ...acquisition, catalogListing: session.options.startListing() } + this.children.sessions.set(sessionId, session) + return acquisition } catch (error) { const { connection } = attempt if (connection && error instanceof AcpConnectionClosedError) { @@ -130,7 +124,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted if (connection && !(await connection.close().catch(() => false))) { // Kept, so the next start or quit closes this same process again; no second one spawns. - this.starts.retainFailed(sessionId, connection) + this.children.starts.retainFailed(sessionId, connection) throw new AgentSessionAcquisitionExitUnprovenError(error) } if (attempt.signal.aborted) { @@ -152,15 +146,35 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } } - async dispatch(input: { - sessionId: string - clientMessageId: string - body: AgentJournalMessageItem - fence: number - requestedAt?: number - beforeDispatch?: () => Promise - }): Promise { - const lost = this.sessions.get(input.sessionId) + open: NonNullable = async (input) => { + const child = this.children.sessions.get(input.sessionId) + if ( + !child || + child.fence !== input.fence || + child.acquisitionGeneration !== input.acquisitionGeneration || + child.ended + ) { + throw new Error('ACP child changed before it opened') + } + try { + return await child.open(input.signal) + } catch (error) { + const failure = acpStartupFailure(child, error) + if (failure) { + const refusal = new AgentSessionAcquisitionRefusal( + `${acpAgentName(child.spec.agent)} requires sign-in`, + 'notSignedIn' + ) + throw failure.detail ? withProviderDiagnostic(refusal, failure.detail) : refusal + } + throw error + } + } + + async dispatch( + input: Parameters[0] + ): Promise { + const lost = this.children.sessions.get(input.sessionId) if (lost && lost.journalClosed !== null) { // The connection broke and the exit is not yet proven: the message never left Orca. return rejectedAcpDispatch(lost) @@ -170,11 +184,25 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (!Array.isArray(prompt)) { return { state: 'rejected', ...prompt } } - if (this.sessions.get(input.sessionId) !== session || session.journalClosed !== null) { + if (this.children.sessions.get(input.sessionId) !== session || session.journalClosed !== null) { // The child ended while its attachments were read: nothing left Orca. return rejectedAcpDispatch(session) } + const { sessionId, fence, signal } = input + const optionsApplied = await applyAcpMessageOptions( + session, + input.executionOptions, + (key, value) => this.setOption({ sessionId, fence, key, value, signal }), + () => + this.children.sessions.get(input.sessionId) === session && session.journalClosed === null, + signal + ) + if (!optionsApplied) { + return rejectedAcpDispatch(session) + } + input.signal?.throwIfAborted() await input.beforeDispatch?.() + input.signal?.throwIfAborted() session.turns.dispatch({ clientMessageId: input.clientMessageId, prompt, @@ -205,14 +233,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return { cancelled: true } } - // Stop is a session boundary for every ACP agent, as in the common pattern: a cancel ends only - // the running turn, and work the agent moved to the background could begin a turn of its own. - // The next send reloads the session. + // Stop is a session boundary: a cancel ends only the running turn, and background work could + // begin another turn. The next send reloads the session. stopEndsSession = (): boolean => true awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { - const session = this.sessions.get(sessionId) - if (session) { + const session = this.children.sessions.get(sessionId) + if (session && isAcpReadySession(session)) { await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs()) } } @@ -226,11 +253,14 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte const session = this.live(input.sessionId) const write = session.options.write(input.key, input.value) if (!write) { - throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) + throw new AgentSessionOptionRejectedError( + `${session.spec.agent} offers no session option named ${input.key}` + ) } session.options.notePick(input.key) // Bounded, and abandoned by a close or Stop: the session's queue waits on it. await writeAcpSessionOption(session.connection, session.options, write, { + key: input.key, agent: session.spec.agent, timeoutMs: this.deps.optionWriteTimeoutMs ?? ACP_OPTION_WRITE_TIMEOUT_MS, ...(input.signal ? { signal: input.signal } : {}) @@ -241,113 +271,63 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readOptions = async (input: { sessionId: string; fence: number }) => readAcpLiveOptions(this.live(input.sessionId)) - readOptionRestoreFailures = (sessionId: string): readonly string[] => - this.sessions.get(sessionId)?.restoreSkipped ?? [] + readCommands = (sessionId: string) => { + const session = this.children.sessions.get(sessionId) + return session && isAcpReadySession(session) ? session.options.readCommands() : undefined + } - readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands() + holdsDispatch = (sessionId: string): boolean => { + const session = this.children.sessions.get(sessionId) + return session ? isAcpReadySession(session) && session.turns.running : false + } + + holdsLiveProviderProcess = (sessionId: string, generation: string): boolean => { + const session = this.children.sessions.get(sessionId) + return session?.acquisitionGeneration === generation && !session.connection.exited + } + + acknowledgeSessionRelease = (sessionId: string): void => { + const session = this.children.sessions.get(sessionId) + if (session?.ended) { + this.children.sessions.delete(sessionId) + } + } stopBackgroundTasks: NonNullable = ( input ) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, input.acknowledge) - backgroundTaskStops = (id: string) => acpChildStopCapabilities(this.sessions.get(id)) + backgroundTaskStops: NonNullable = ( + sessionId + ) => acpChildStopCapabilities(this.children.sessions.get(sessionId)) - closeSession = (sessionId: string): Promise => this.close(sessionId) - disposeSession = (sessionId: string): Promise => this.close(sessionId) - releaseAcquisition = (input: { sessionId: string }) => this.close(input.sessionId) + closeSession = (sessionId: string): Promise => this.children.close(sessionId) + disposeSession = (sessionId: string): Promise => this.children.close(sessionId) + releaseAcquisition = (input: { sessionId: string }) => this.children.close(input.sessionId) hasOwnedAcquisition = (sessionId: string): boolean => - this.sessions.has(sessionId) || this.starts.failedSessionIds().includes(sessionId) + this.children.sessions.has(sessionId) || + this.children.starts.failedSessionIds().includes(sessionId) /** After a sink failure: the exit is recovered as unexpected. */ - forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) + forceCloseSession = (sessionId: string): Promise => this.children.stop(sessionId, false) async closeAll(): Promise { const copiedChildren = this.copies.closeAll() - const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()]) + const ids = new Set([ + ...this.children.sessions.keys(), + ...this.children.starts.failedSessionIds() + ]) // A start still under way is the host's to abort: its teardown does, before this runs. - const proven = await Promise.all([...ids].map((sessionId) => this.stop(sessionId, true))) + const proven = await Promise.all( + [...ids].map((sessionId) => this.children.stop(sessionId, true)) + ) if (!(await copiedChildren) || proven.includes(false)) { throw new Error('an ACP agent child could not be proven stopped') } } - /** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */ - private async close(sessionId: string): Promise { - const connection = this.sessions.get(sessionId)?.connection - const closed = await this.stop(sessionId, true) - if (closed && connection?.processTreeUnproven) { - throw new AgentSessionAcquisitionRootExitObservedError( - new Error( - `${this.deps.spec.agent} ACP agent exited, but its process tree was not proven gone` - ) - ) - } - return closed - } - - /** True only once every child is proven gone, or when this adapter runs none for the session: - * a failed start's child, and the session's own. */ - private async stop(sessionId: string, requested = true): Promise { - const [failedStart, session] = await Promise.all([ - this.starts.stopFailed(sessionId), - this.stopSession(sessionId, requested) - ]) - return failedStart && session - } - - private async stopSession(sessionId: string, requested: boolean): Promise { - const session = this.sessions.get(sessionId) - if (!session || session.ended) { - return true - } - if (requested && session.journalClosed === null) { - // A close, dispose or quit ends a running turn as a Stop does before the child goes. - session.closeRequested = true - await windDownAcpTurn(session, this.stopGraceMs()) - if (session.ended) { - return true - } - } - // A connection loss already decided why the child ends; a later stop does not relabel it. - if (session.journalClosed === null) { - session.closeRequested ||= requested - session.lane.flush() - } - const proven = await session.connection.close() - if (proven) { - this.finish(session, session.exitObservedAt ?? this.now()) - } - return proven - } - - /** The child's exit is proven: the host hears it, and nothing of the child stays here. */ - private finish(session: AcpStructuredSession, observedAt: number): void { - endAcpStructuredSession(session, observedAt, this.deps.onEvent) - if (this.sessions.get(session.sessionId) === session) { - this.sessions.delete(session.sessionId) - } - } - - /** The connection broke while the child may still run: nothing more it says can be journaled, so - * the journal closes now and the child is stopped; the host hears `ended` once that is proven. */ - private connectionLost(session: AcpStructuredSession | null, error: Error): void { - if ( - !session || - this.sessions.get(session.sessionId) !== session || - session.journalClosed !== null - ) { - return - } - closeAcpSessionJournal( - session, - `${session.spec.agent} ACP connection closed: ${error.message || error.name}`, - error - ) - void this.stop(session.sessionId, false) - } - private live(sessionId: string): AcpStructuredSession { - const session = this.sessions.get(sessionId) - if (!session || session.journalClosed !== null) { + const session = this.children.sessions.get(sessionId) + if (!session || !isAcpReadySession(session) || session.journalClosed !== null) { throw new Error(`no live ${this.deps.spec.agent} child owns ${sessionId}`) } return session diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index d0ed4bc3e56..54cf8b8e555 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -20,6 +20,7 @@ import type { AcpStructuredLane } from './acp-structured-lane' import type { AcpStructuredOptions } from './acp-structured-options' import type { AcpStructuredPrompts } from './acp-structured-prompts' import type { AcpStructuredTurns } from './acp-structured-turns' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' export type AcpStructuredSession = { sessionId: string @@ -33,8 +34,6 @@ export type AcpStructuredSession = { prompts: AcpStructuredPrompts options: AcpStructuredOptions turns: AcpStructuredTurns - /** Saved picks the agent refused when this child started. */ - restoreSkipped: readonly string[] /** Orca asked this child to stop; its exit is then a requested close. */ closeRequested: boolean /** Why nothing this child says reaches the journal any more; null while it does. */ @@ -43,6 +42,8 @@ export type AcpStructuredSession = { ended: boolean exitObservedAt: number | null unbindReadingControl?: () => void + dispose: () => void + open: (signal: AbortSignal) => Promise } /** Every agent frame goes through here once the lane exists: options and commands first, so a @@ -156,7 +157,9 @@ export function endAcpStructuredSession( } /** Only for a send refused before its prompt was written. */ -export function rejectedAcpDispatch(session: AcpStructuredSession): AgentSessionDispatchOutcome { +export function rejectedAcpDispatch( + session: Pick +): AgentSessionDispatchOutcome { return { state: 'rejected', ...agentSessionFailureWords( diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index 2343ef650de..e8425302577 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -1,9 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { z } from 'zod' -import { providerDiagnosticOf } from '../../shared/agent-session-failure' -import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { agentSessionFailureSentence } from '../../shared/agent-session-failure-words' -import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { AcpSignInPromptError } from './acp-errors' import { ACP_LAUNCH_SPECS } from './acp-launch-specs' @@ -101,11 +99,13 @@ it('ends a running turn signed out when the connection closes for sign-in', asyn it('starts signed out without Orca words when the connection closes for sign-in', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}) }) - const acquired = rig.acquire().catch((failure: unknown) => failure) + // Session setup runs in the start's open, which the rig awaits after the acquire. + const starting = rig.acquire() await rig.frame('session/new') const internal = 'The agent asked to sign in in a browser' AcpSessionRuntime.prototype.close.call(rig.child(), new AcpSignInPromptError(internal)) - const error = await acquired + await starting + const error = rig.openFailure() // Session setup re-types sign-in answers; this close must stay Orca's, words and all. expect(error).toMatchObject({ reason: 'notSignedIn' }) expect(providerDiagnosticOf(error)).toBeUndefined() @@ -119,16 +119,35 @@ it.each(ACP_LAUNCH_SPECS)( spec, script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, detail)) }) - const error = await rig.acquire().catch((failure: unknown) => failure) - expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal) - expect(error).toMatchObject({ reason: 'notSignedIn' }) - expect(providerDiagnosticOf(error)).toEqual({ text: detail, audience: 'person' }) - const fact = providerStartupFailureFact(error) - expect(fact.detail?.text).toBe(detail) + // The host receives the refusal from open and then closes the child. + await rig.acquire() + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) + expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' }) + const fact = agentSessionFailureFact('notSignedIn', { + detail: providerDiagnosticOf(rig.openFailure()) + }) expect(agentSessionFailureSentence(fact, 'row', { agentName: spec.agent })).toContain(detail) } ) +it.each(ACP_LAUNCH_SPECS)( + "keeps $agent's sign-in words over log lines it wrote before refusing its start", + async (spec) => { + const detail = 'Sign-in credentials are missing.' + const rig = await openAcpAdapterRig({ + spec, + script: (agent) => + agent.on('session/new', (frame) => { + rig.child().stderr = 'warn: telemetry endpoint unreachable' + agent.fail(frame, -32000, detail) + }) + }) + await rig.acquire() + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) + expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' }) + } +) + it.each([ [ 'No API key found for anthropic.\n\nUse /login, set an API key environment variable, or create /host/agent.db', diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index 421ecd31f64..bcd27f99c0c 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -166,7 +166,7 @@ describe('a send while a Grok prompt runs', () => { await new Promise((resolve) => setTimeout(resolve, 120)) await rig.settle() expect(rig.sent('session/cancel')).toHaveLength(1) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first']) // A Stop sends its own cancel; the host's grace, not the cancel, ends Grok. @@ -223,7 +223,7 @@ describe('a send while a turn Grok began itself runs', () => { // `a` runs a whole turn: a steer's cancel never ends Grok. await new Promise((resolve) => setTimeout(resolve, 120)) await rig.settle() - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) agent.reply(a, { stopReason: 'end_turn' }) const b = await rig.frame('session/prompt', 1) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index 920937abd2a..87bef978917 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -1,14 +1,13 @@ import { agentSessionFailureFact, providerDiagnostic, - withProviderDiagnostic + type SubmissionRejectionFact } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError, AcpAuthRequiredError, AcpSignInPromptError } from './acp-errors' -import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' /** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] @@ -45,18 +44,13 @@ export function acpAuthenticationRequired(dialect: AcpDialect, error: unknown): ) } -export function acpSignInRequiredRefusal( - agent: string, +/** A sign-in refusal in the agent's own words. */ +export function acpSignInRequiredFailure( dialect: AcpDialect, error: AcpAgentError -): AgentSessionAcquisitionRefusal { - return withProviderDiagnostic( - new AgentSessionAcquisitionRefusal( - `${agent} reported that it is not signed in: ${error.message}`, - 'notSignedIn' - ), - providerDiagnostic(acpPromptErrorDetail(dialect, error), 'person') - ) +): SubmissionRejectionFact { + const detail = providerDiagnostic(acpPromptErrorDetail(dialect, error), 'person') + return agentSessionFailureFact('notSignedIn', detail ? { detail } : {}) } /** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 6d09209b30e..6ca6b61b94e 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -287,7 +287,8 @@ async function createSurface( agent: intent.agent, ...(intent.sessionOptions ? { options: intent.sessionOptions } : {}), ...(intent.sessionId ? { sessionId: intent.sessionId } : {}), - ...(reservedTabId ? { tabId: reservedTabId } : {}) + ...(reservedTabId ? { tabId: reservedTabId } : {}), + ...(intent.prompt ? {} : { withoutPrompt: true as const }) }) return { outcome: { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 93160d4b72a..a5a0224122a 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -22,6 +22,9 @@ export type AgentLaunchSurfaceFactory = { /** The caller-reserved tab id (the tab half of `paneKey`), so the host records where this chat * is shown; absent records the id clients derive. */ tabId?: string + /** The launch carries no prompt, so a caller that sends the first message itself may get the + * chat at rest, its agent started by that message. */ + withoutPrompt?: true }): Promise createTerminalAgent(args: { worktreeId: string diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index a48b3e8a210..0cf0523eb9a 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -44,8 +44,7 @@ function definedEnv(env: Record): Record void + platform: NodeJS.Platform = process.platform ): ClaudeCodeProcessSpawn { let managed: ManagedProviderProcess | null = null return { @@ -63,8 +62,7 @@ export function createClaudeCodeProcessSpawn( inheritedEnv: definedEnv(options.env), site: 'claude-stream-json-teardown', policy: (supervised) => claudeChildClosePolicy(supervised, platform), - acceptClose: claudeChildCloseProven, - ...(onOutput ? { onOutput } : {}) + acceptClose: claudeChildCloseProven } ) // The SDK drains stderr only for its own local spawn; the managed process drains it here. diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index c5a05cfb556..c2f833314f9 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -58,6 +58,9 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async ({ fence, spawnToken, events }) => { sink = events ?? null return { 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 6ad422fa160..43fdcdee9c4 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -1,3 +1,4 @@ +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -98,7 +99,14 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist readOptions: async () => ({ models: [], current: { model: 'opus' } }) }, agents, - store: { getRecord: () => undefined } + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + sessionId: SESSION, + provider: 'claude', + options: { model: 'opus' } + }) + } }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => running, diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 5466c540355..4ab8d8550e5 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -558,6 +558,7 @@ describe('Claude stream-json connection', () => { const session = { connection, options: new Map(), + reusableOptions: new Map(), reportedOptions: {}, startup: { state: 'proven' } } as unknown as ClaudeSession diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 828a58e043a..dc446b3b1b3 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -81,8 +81,6 @@ export type ClaudeStreamJsonConnectionHandlers = { /** The root process exited, reported once. `expected`: a close had begun, so it is that close's * end, even one that ran out of its own escalation first and came back unproven. */ onExit?: (error: Error, exit?: { expected: boolean }) => void - /** Any stdout or stderr chunk from the child. */ - onOutput?: () => void } /** @@ -139,7 +137,7 @@ export async function openClaudeStreamJsonConnection( queryImpl?: typeof ClaudeAgentSdk.query ): Promise { const { query } = await loadClaudeAgentSdk() - const spawner = createClaudeCodeProcessSpawn(spawnImpl, process.platform, handlers.onOutput) + const spawner = createClaudeCodeProcessSpawn(spawnImpl, process.platform) const inbox = createClaudeUserMessageQueue() const session = (queryImpl ?? query)({ prompt: inbox.messages, diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 809a81d7026..a0efb5eddba 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -106,7 +106,6 @@ export async function resolveClaudeAcquisitionLaunch(args: { options: spawn.sdkOptions, savedOptions: { options: spawn.options, - skipped: spawn.skipped, fastModeAtStart: spawn.fastModeAtStart } } diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 4b9718ae45f..97fb9fa6d83 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -29,7 +29,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), + reusableOptions: new Map(), launchedModel: null, fastModeAtStart: false, capabilities: [], diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 3467a474e0b..3604e1e86cf 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -20,6 +20,7 @@ import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session- import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' import { agentSessionFailureFact, + providerDiagnostic, type SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' @@ -28,6 +29,8 @@ import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' +import { isAgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { ClaudeControlRequestError } from './claude-stream-json-connection' const MAX_ACTIVE_DISPATCH_WAITERS = 64 @@ -171,6 +174,19 @@ export async function dispatchClaudeTurn( if (error instanceof AgentSessionPreDispatchError) { throw error } + if (isAgentSessionOptionRejectedError(error)) { + return { + state: 'rejected', + ...claudeDispatchRejection( + agentSessionFailureFact( + 'providerRejected', + error.cause instanceof ClaudeControlRequestError + ? { detail: providerDiagnostic(error.message, 'person') } + : {} + ) + ) + } + } return { state: 'rejected', ...claudeWriteFailureRejection(error) } } const waiter = replay.waiter diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index fa48656d6d7..03c57dd5563 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -17,7 +17,6 @@ import type { StructuredAgentSessionMutationContext } from '../native-chat/agent import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog' -import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { ClaudeStructuredSessionAdapter, type ClaudeStructuredSessionEvent @@ -418,10 +417,7 @@ describe('Claude effort default at rest', () => { const reported = (await claudeOpened(adapter, SESSION)).reportedOptions expect(reported).not.toHaveProperty('effort') - // The record the start persists names the listed row the catalog learned under. - const record = restingRecord( - nativeSessionOptionsFromReport({ reported: reported, restoreSkipped: [] }) - ) + const record = restingRecord({ model: 'opus[1m]' }) expect(record.options).toEqual({ model: 'opus[1m]' }) expect((await readAtRest(store, record)).current.effort).toBe('medium') expect(record.options).toEqual({ model: 'opus[1m]' }) diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 3305a40d77a..98042025ecc 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { setClaudeStructuredOption } from './claude-structured-options' -import { readClaudeSettingsEffort } from './claude-structured-session-options' +import { + readClaudeStructuredSessionOptions, + readClaudeSettingsEffort +} from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import { USER_MESSAGE, acquired, fakeClaude } from './claude-structured-session-test-support' @@ -26,7 +29,7 @@ function sessionWith( optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), + reusableOptions: new Map(), connection: { supportedModels: async () => { calls.push('list_models') @@ -199,6 +202,8 @@ describe('Claude effort against the model that must run it', () => { setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) ).resolves.toEqual({ model: 'sonnet', effort: 'max' }) expect(calls).toEqual(['list_models', 'apply:max']) + await readClaudeStructuredSessionOptions(session, undefined) + expect(session.reusableOptions.get('effort')).toBe('max') expect(session.confirmedOptions.has('effort')).toBe(false) }) diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts new file mode 100644 index 00000000000..9a043e66da7 --- /dev/null +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from 'vitest' +import { + acquired, + claudeFrame, + fakeClaude, + USER_MESSAGE +} from './claude-structured-session-test-support' +import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options' +import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +const MODELS = [ + { value: 'default', resolvedModel: 'claude-sonnet-5' }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'opus', + resolvedModel: 'claude-opus-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } +] + +describe('Claude accepted message choices', () => { + it('applies a held message A before its prompt, then message B on the existing child', async () => { + const order: string[] = [] + let currentEffort = 'high' + const claude = fakeClaude({ + routes: { + list_models: () => MODELS, + set_model: ({ model } = {}) => order.push(`model:${String(model)}`), + apply_flag_settings: ({ settings } = {}) => { + order.push(`effort:${JSON.stringify(settings)}`) + if ( + typeof settings === 'object' && + settings !== null && + 'effortLevel' in settings && + typeof settings.effortLevel === 'string' + ) { + currentEffort = settings.effortLevel + } + } + } + }) + const adapter = await acquired(claude) + const connection = claude.connections[0]! + connection.getSettings = async () => ({ effective: { effortLevel: currentEffort } }) + const send = connection.send + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let entered!: () => void + const enteredSend = new Promise((resolve) => { + entered = resolve + }) + let first = true + connection.send = async (message, beforeDispatch) => { + if (first) { + first = false + entered() + await held + } + const result = await send(message, beforeDispatch) + order.push('prompt') + return result + } + + const a = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'A', + body: USER_MESSAGE, + executionOptions: { model: 'sonnet', effort: 'low' }, + fence: 7 + }) + await enteredSend + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + order.length = 0 + release() + await expect(a).resolves.toEqual({ state: 'admitted' }) + expect(order).toEqual(['model:sonnet', 'effort:{"effortLevel":"low"}', 'prompt']) + + order.length = 0 + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'B', + body: USER_MESSAGE, + executionOptions: { model: 'opus', effort: 'high' }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(order).toEqual(['model:opus', 'effort:{"effortLevel":"high"}', 'prompt']) + }) + + it('restores A after an applied unanswered B and an uncorrelated old init', async () => { + let actual: string | undefined = 'sonnet' + const claude = fakeClaude({ initModels: MODELS, routes: { list_models: () => MODELS } }) + const adapter = await acquired(claude) + const connection = claude.connections[0]! + connection.setModel = async (model) => { + actual = model + if (model === 'opus') { + throw new Error('answer lost') + } + } + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + ).rejects.toThrow('answer lost') + claudeFrame(connection, { type: 'system', subtype: 'init', model: 'sonnet' }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'restore', + body: USER_MESSAGE, + executionOptions: { model: 'sonnet' }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(actual).toBe('sonnet') + expect(connection.sent).toHaveLength(1) + }) + + it('keeps saved Fast for an older partial send, freezes it, and honors explicit false', async () => { + let fastMode = true + const supported = MODELS.map((model) => ({ ...model, supportsFastMode: true })) + const claude = fakeClaude({ + initModels: supported, + routes: { + list_models: () => supported, + apply_flag_settings: ({ settings } = {}) => { + if ( + typeof settings === 'object' && + settings !== null && + 'fastMode' in settings && + typeof settings.fastMode === 'boolean' + ) { + fastMode = settings.fastMode + } + } + } + }) + const adapter = await acquired(claude) + const saved = { model: 'sonnet', effort: 'high', fastMode: 'true' } + const ctx = { agents: claudeAndCodexAgents(), agent: 'claude', persistedOptions: saved } + const captured = captureStructuredExecutionOptions(ctx, { model: 'sonnet', effort: 'high' }) + if ('refusal' in captured) { + throw new Error(captured.refusal.message) + } + saved.fastMode = 'false' + const connection = claude.connections[0]! + connection.getSettings = async () => ({ effective: { effortLevel: 'high', fastMode } }) + for (const [id, options] of [ + ['old', captured.options], + ['off', { ...captured.options, fastMode: 'false' }] + ] as const) { + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: id, + body: USER_MESSAGE, + executionOptions: options, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + } + expect(captured.options.fastMode).toBe('true') + expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual( + expect.arrayContaining([ + { subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }, + { subtype: 'apply_flag_settings', params: { settings: { fastMode: false } } } + ]) + ) + expect(connection.sent).toHaveLength(2) + }) + + it('restores the configured model and effort for an empty snapshot', async () => { + let currentEffort = 'high' + const claude = fakeClaude({ + routes: { + list_models: () => MODELS, + apply_flag_settings: ({ settings } = {}) => { + if ( + typeof settings === 'object' && + settings !== null && + 'effortLevel' in settings && + typeof settings.effortLevel === 'string' + ) { + currentEffort = settings.effortLevel + } + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + await vi.waitFor(() => + expect(events).toContainEqual(expect.objectContaining({ type: 'options-reported' })) + ) + claude.connections[0]!.getSettings = async () => ({ effective: { effortLevel: currentEffort } }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'A', + body: USER_MESSAGE, + executionOptions: { model: 'opus', effort: 'low' }, + fence: 7 + }) + const connection = claude.connections[0]! + claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5' }) + connection.calls.length = 0 + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'default', + body: USER_MESSAGE, + executionOptions: {}, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect( + connection.calls.filter((call) => ['set_model', 'apply_flag_settings'].includes(call.subtype)) + ).toEqual([ + { subtype: 'set_model', params: { model: 'claude-sonnet-5' } }, + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } } + ]) + expect(connection.sent).toHaveLength(2) + }) +}) diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts new file mode 100644 index 00000000000..3cef26e2dd1 --- /dev/null +++ b/src/main/claude/claude-structured-message-options.ts @@ -0,0 +1,100 @@ +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { listedModels } from './claude-structured-model-catalog' +import { setClaudeStructuredOption } from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' + +/** Applies the accepted message's choices while its prompt is still in the SDK input queue. */ +export async function applyClaudeMessageOptions( + session: ClaudeSession, + options: AgentSessionExecutionOptions, + timeoutMs: number | undefined, + signal?: AbortSignal +): Promise { + throwIfSignalAborted(signal) + const model = options.model ?? (await defaultModelForMessage(session, timeoutMs, signal)) + if (model && session.reusableOptions.get('model') !== model) { + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs), + signal + ) + } + + throwIfSignalAborted(signal) + const effort = options.effort ?? session.configuredDefaults?.effort + if (effort && session.reusableOptions.get('effort') !== effort) { + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs), + signal + ) + } else if (!effort && session.options.has('effort')) { + throw new AgentSessionOptionRejectedError('Claude did not report its configured effort') + } + + throwIfSignalAborted(signal) + const fastMode = options.fastMode + if (fastMode !== undefined && session.reusableOptions.get('fastMode') !== fastMode) { + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs), + signal + ) + } + for (const [key, value] of Object.entries({ model, effort, fastMode })) { + if (value !== undefined && session.reusableOptions.get(key) !== value) { + throw new AgentSessionOptionRejectedError(`Claude did not apply ${key} ${value}`) + } + } + throwIfSignalAborted(signal) +} + +async function defaultModelForMessage( + session: ClaudeSession, + timeoutMs: number | undefined, + signal?: AbortSignal +): Promise { + if (session.configuredDefaults?.model) { + return session.configuredDefaults.model + } + if (!session.options.has('model')) { + return undefined + } + const response = await waitForPromiseWithSignal( + session.connection.supportedModels({ timeoutMs }), + signal + ).catch(() => null) + throwIfSignalAborted(signal) + const listed = listedModels(response ? { models: response } : null) + const fallback = listed.find((model) => model.isDefault)?.id + if (!fallback) { + throw new AgentSessionOptionRejectedError('Claude did not report its default model') + } + return fallback +} + +export function dispatchClaudeMessage( + session: ClaudeSession, + input: Parameters[0], + timeoutMs: number | undefined +): ReturnType { + const messageOptions = input.executionOptions + return dispatchClaudeTurn( + session, + input, + messageOptions === undefined + ? input.beforeDispatch + : async () => { + await input.beforeDispatch?.() + try { + await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal) + input.signal?.throwIfAborted() + } catch (error) { + throw error instanceof AgentSessionOptionRejectedError + ? error + : new AgentSessionOptionRejectedError(error) + } + } + ) +} diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts index f4034400cbf..dd821c426a3 100644 --- a/src/main/claude/claude-structured-model-confirmation.test.ts +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -186,6 +186,7 @@ describe('Claude effort the settings readback cannot report', () => { reportedOptions: {}, optionMutationSequence: 0, confirmedOptions: new Set(), + reusableOptions: new Map(), connection: { supportedModels: async () => { calls.push('list_models') diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts index a44ca0232b8..a2eb902e8dd 100644 --- a/src/main/claude/claude-structured-model-preflight.test.ts +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -22,7 +22,7 @@ function sessionWith(catalog: readonly Record[] | 'unavailable' optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), + reusableOptions: new Map(), connection: { supportedModels: async () => { calls.push('list_models') diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts index d5ac5fd4d2d..26b5e790d8f 100644 --- a/src/main/claude/claude-structured-option-confirmation.test.ts +++ b/src/main/claude/claude-structured-option-confirmation.test.ts @@ -168,6 +168,7 @@ describe('confirmation never outlives the write it belongs to', () => { reportedOptions: {}, optionMutationSequence: 0, confirmedOptions: new Set(), + reusableOptions: new Map(), connection: { supportedModels: async () => CATALOG, applyFlagSettings: async (s: { effortLevel?: string }) => { diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index 9416bf947a5..eab0aef168d 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -36,7 +36,6 @@ describe('the context ring after a session option write', () => { const s = ringSession() adoptClaudeStructuredSpawnOptions(s.session, { options: new Map([['model', 'opus[1m]']]), - skipped: [], fastModeAtStart: false }) expect(s.respond('turn-a', 1_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 64ada44671d..bb05f48d2b7 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -38,7 +38,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), + reusableOptions: new Map(), launchedModel: null, fastModeAtStart: false, capabilities: [], @@ -346,6 +346,8 @@ describe('Claude structured Fast mode', () => { ).resolves.toMatchObject({ model: 'haiku', fastMode: 'false' }) expect(session.reportedOptions.fastMode).toBe(true) expect(session.confirmedOptions.has('fastMode')).toBe(false) + expect(session.reusableOptions.get('model')).toBe('haiku') + expect(session.reusableOptions.has('fastMode')).toBe(false) }) it('treats an unrecognized provider disabled reason as unavailable', async () => { diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 042c513f597..d49296f07b0 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -131,6 +131,11 @@ export async function setClaudeStructuredOption( ? claudeModelFastModeSupport(session, listed, input.value) : null const modelWasConfirmed = readClaudeCurrentModel(session).confirmed + session.reusableOptions.delete(input.key) + if (input.key === 'model') { + session.reusableOptions.delete('effort') + session.reusableOptions.delete('fastMode') + } const mutationSequence = ++session.optionMutationSequence // Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it. const held = session.options.get(input.key) @@ -160,6 +165,7 @@ export async function setClaudeStructuredOption( if (mutationSequence !== session.optionMutationSequence) { return Object.fromEntries(session.options) } + session.reusableOptions.set('model', input.value) session.options.set('model', input.value) session.options.set('fastMode', 'false') session.confirmedOptions.delete('effort') @@ -190,6 +196,11 @@ export async function setClaudeStructuredOption( if (mutationSequence !== session.optionMutationSequence) { return Object.fromEntries(session.options) } + if (input.key === 'model' || (input.key === 'effort' && UNREPORTED_EFFORTS.has(input.value))) { + session.reusableOptions.set(input.key, input.value) + } else if (adopted !== null) { + session.reusableOptions.set(input.key, String(adopted)) + } if (input.key === 'fastMode' && typeof adopted === 'boolean') { session.reportedOptions.fastMode = adopted } diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index bc8e49065b6..d846553af1d 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -41,8 +41,7 @@ function modelNotFound(parentToolUseId: string | null = null): Record() + options: new Map(picked === null ? [] : [['model', picked]]) } } @@ -52,7 +51,6 @@ describe("the CLI's word that a launched model does not exist", () => { expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(RETIRED) expect(session.options.has('model')).toBe(false) - expect([...session.restoreSkippedOptions]).toEqual(['model']) expect(retireClaudeLaunchedModel(session, modelNotFound())).toBeNull() }) @@ -63,7 +61,6 @@ describe("the CLI's word that a launched model does not exist", () => { ['a launch that named no model', sessionLaunchedWith(null), modelNotFound()] ])('keeps the saved model for %s', (_case, session, message) => { expect(retireClaudeLaunchedModel(session, message)).toBeNull() - expect([...session.restoreSkippedOptions]).toEqual([]) }) }) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index 3b233201722..3bbe76dc06e 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -8,9 +8,9 @@ import type { } from './claude-structured-session-state' /** The launched model, once, when a root reply says it does not exist and the chat still has it - * picked: the session stops holding it, so the record drops it. Null otherwise. */ + * picked: the live session stops holding it. Saved picks remain the user's. */ export function retireClaudeLaunchedModel( - session: Pick, + session: Pick, message: Record ): string | null { const launched = session.launchedModel @@ -24,7 +24,7 @@ export function retireClaudeLaunchedModel( return null } session.options.delete('model') - session.restoreSkippedOptions.add('model') + session.retiredModel = launched return launched } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 095689ecfd6..b855bc499e8 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -97,6 +97,14 @@ export async function acquireClaudeSession({ // running; set_model answers success for a model it never resolves, so this // report is the session's only adoption evidence. if (liveSession && init.model) { + if (liveSession.reusableOptions.get('model') !== init.model) { + liveSession.reusableOptions.delete('model') + liveSession.reusableOptions.delete('effort') + liveSession.reusableOptions.delete('fastMode') + } + if (liveSession.optionMutationSequence === 0) { + liveSession.reusableOptions.set('model', init.model) + } liveSession.reportedOptions.model = init.model liveSession.reportedModelMutation = liveSession.optionMutationSequence } @@ -189,7 +197,6 @@ export async function acquireClaudeSession({ onMessage, canUseTool, onUserDialog, - ...(input.onOutput ? { onOutput: input.onOutput } : {}), onFault: (error) => { childEnded ??= error initProof.reject(error) @@ -277,7 +284,7 @@ export async function acquireClaudeSession({ ) } // The host calls open after publication and holds input until it settles. - return { ...publication.acquisition, providerChildPhase: 'starting' } + return publication.acquisition } catch (error) { unbindReadingControl?.() const acquisitionError = await resolveClaudeAcquisitionError({ diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index 413d2db4fb6..0fa3c8f287e 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -12,6 +12,25 @@ import { } 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.toEqual({ 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() diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 87c04b453ec..6b93a2b9943 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -7,10 +7,6 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV as SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env' -import { - ClaudeControlRequestError, - ClaudeControlRequestTimeoutError -} from './claude-agent-sdk-control-requests' import type { ClaudeStructuredSessionAdapter, ClaudeStructuredSessionEvent @@ -129,89 +125,15 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) - // Left out, not refused: the record takes the provider's own Fast, as before. - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ - reportedOptions: { fastMode: false } - }) - }) - - // No message waits on it: the launch carries no Fast, and the start applies it once the CLI's - // settings show no per-session opt-in, as the live CLI reports with no settings file at all. - it('applies a saved Fast on to a new conversation once its settings allow it, and keeps the pick', async () => { - const claude = fakeClaude({ - settings: { applied: {}, effective: {}, sources: {} }, - initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: 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.map((call) => call.subtype)).toEqual([ - 'initialize', - 'get_settings', - 'apply_flag_settings' - ]) - expect(claude.connections[0].calls.at(-1)).toEqual({ - subtype: 'apply_flag_settings', - params: { settings: { fastMode: true } } - }) - expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ reportedOptions: { fastMode: true } }) }) - it.each([ - ['refuses', new ClaudeControlRequestError('apply_flag_settings', 'not for this org'), false], - ['never answers', new ClaudeControlRequestTimeoutError('apply_flag_settings'), true] - ] as const)( - 'settles a saved Fast on a new conversation when the CLI %s its apply', - async (_case, failure, kept) => { - let answerApply: (error: Error) => void = () => {} - const claude = fakeClaude({ - settings: { applied: {}, effective: {}, sources: {} }, - initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }], - routes: { - apply_flag_settings: () => new Promise((_, reject) => (answerApply = reject)) - } - }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, {}, events) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - - // Started, with the pick, while the apply is still unanswered: nothing waits on it. - expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings') - expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ - reportedOptions: { fastMode: true } - }) - answerApply(failure) - await tick() - - // A refusal drops the child's value; silence keeps it wanted and unconfirmed. - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode']) - const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) - expect(options.current.fastMode === true).toBe(kept) - expect(options.current.confirmed ?? []).not.toContain('fastMode') - } - ) - - it('drops a saved Fast on from a new conversation whose model the CLI lists without it', async () => { + 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: false }] + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] }) const adapter = adapterFor(claude) @@ -221,15 +143,16 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { 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([]) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode']) }) // The chat's permission mode is the start's, never a saved option the CLI is handed. - it('self-heals a persisted effort the CLI would refuse, and launches the start mode', async () => { + 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') @@ -237,7 +160,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const start = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options } await adapter.acquire({ ...start, permissionMode: 'accept-edits' }) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['effort']) expect(claude.connections[0].launch.options).not.toHaveProperty('effort') expect(resolveLaunch.mock.calls[0]?.[0].permissionMode).toBe('accept-edits') }) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index d375ce188fe..b70531faa5c 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -7,12 +7,12 @@ import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopCurrentClaudeBackgroundTasks } from './claude-structured-control-actions' -import { dispatchClaudeTurn } from './claude-structured-dispatch' import { claudeHoldsDispatch } from './claude-command-lifecycle' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' +import { dispatchClaudeMessage } from './claude-structured-message-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { ClaudeAcquisitionRegistry, @@ -164,7 +164,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) + dispatchClaudeMessage(this.session(input.sessionId), input, this.deps.requestTimeoutMs) compact: NonNullable = (input) => dispatchClaudeCommand(this.session(input.sessionId), input.command) @@ -256,9 +256,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readOptionRestoreFailures = (sessionId: string): readonly string[] => [ - ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) - ] + readOptionRestoreFailures = (sessionId: string): readonly string[] => { + const session = this.sessions.get(sessionId) + return session?.retiredModel !== undefined && !session.options.has('model') ? ['model'] : [] + } releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts index dbe751882dd..aca42b67ded 100644 --- a/src/main/claude/claude-structured-session-commands.test.ts +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { ClaudeAtRestCommandCatalog } from './claude-at-rest-commands' import { adapterFor, fakeClaude, @@ -113,3 +115,18 @@ it('keeps a buffered stream catalog newer than the initialization response', asy 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 () => '/work', + 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() +}) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 2550b6bab18..1ca91be20d8 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -53,6 +53,22 @@ export function observeClaudeSettingsApplied(session: ClaudeSession, settings: u } } +/** Applied effort describes execution; effective effort describes the underlying settings. */ +export function observeClaudeSettingsExecutionEffort( + session: ClaudeSession, + settings: unknown +): void { + const applied = readClaudeSettingsApplied(settings) + const effort = applied ? applied.effort : readClaudeSettingsEffort(settings) + if (session.reusableOptions.get('effort') !== 'max') { + if (effort) { + session.reusableOptions.set('effort', effort) + } else if (applied) { + session.reusableOptions.delete('effort') + } + } +} + export function readClaudeSettingsFastMode(settings: unknown): boolean | null { const value = record(record(settings)?.effective)?.fastMode return typeof value === 'boolean' ? value : null @@ -236,10 +252,12 @@ function observeClaudeSettingsReadback( const fastMode = readClaudeSettingsFastMode(settings) const perSessionOptIn = readClaudeSettingsFastModePerSessionOptIn(settings) observeClaudeSettingsApplied(session, settings) + observeClaudeSettingsExecutionEffort(session, settings) if (effort) { session.reportedOptions.effort = effort } if (fastMode !== null) { + session.reusableOptions.set('fastMode', String(fastMode)) session.reportedOptions.fastMode = fastMode if (decodedFastMode(session) !== undefined) { session.options.set('fastMode', String(fastMode)) @@ -293,6 +311,7 @@ export function claudeStructuredSessionOptionsFrom( readMutationSequence === session.optionMutationSequence ) { session.options.set('fastMode', 'false') + session.reusableOptions.delete('fastMode') session.confirmedOptions.delete('fastMode') desiredFastMode = false } diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 5630e0f9598..17b6dfa62c9 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -63,7 +63,10 @@ export function createClaudeSessionPublication(input: { reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), + // Only choices carried in this child's launch can skip their first write. + reusableOptions: new Map( + [...(input.options ?? [])].filter(([key]) => key === 'model' || key === 'effort') + ), launchedModel: null, fastModeAtStart: false, translator: input.translator, diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 42cc302773c..4dbb69d407f 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -3,9 +3,11 @@ import { adapterAtPublishFor, fakeClaude, identityFor, - claudeOpened + claudeOpened, + USER_MESSAGE } from './claude-structured-session-test-support' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { CLAUDE_INITIALIZE_TIMEOUT_MS } from './claude-structured-session-startup' const ACQUIRE = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9' } @@ -22,7 +24,7 @@ describe('Claude host-awaited open', () => { const claude = fakeClaude({ initDelayMs: 12_000 }) const { adapter, events } = adapterFor(claude) const acquisition = await adapter.acquire(ACQUIRE) - expect(acquisition).toMatchObject({ providerChildPhase: 'starting', process: { pid: 4321 } }) + 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) @@ -60,6 +62,47 @@ describe('Claude host-awaited open', () => { await adapter.closeAll() }) + 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.toEqual({ state: 'admitted' }) + expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([ + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }, + { 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) @@ -79,6 +122,19 @@ describe('Claude host-awaited open', () => { 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 = expect(claudeOpened(adapter, 'session-1')).rejects.toThrow( + 'did not answer initialize' + ) + await vi.advanceTimersByTimeAsync(CLAUDE_INITIALIZE_TIMEOUT_MS) + await open + 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) @@ -87,7 +143,7 @@ describe('Claude host-awaited open', () => { const open = adapter.open({ sessionId: 'session-1', fence: 7, - acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + acquisitionGeneration: acquisition.acquisitionGeneration, signal: controller.signal }) controller.abort(new Error('host start expired')) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index f924756e622..87a35be71a7 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,5 +1,5 @@ -// Claude initialize is awaited by the host after process publication. Settings enrichment follows -// independently; the host owns a failed open and its single close. +// Claude initialize is awaited by the host after process publication, under its own bound. Settings +// enrichment follows independently; the host owns a failed open and its single close. import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' @@ -21,6 +21,8 @@ import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, + observeClaudeSettingsExecutionEffort, + readClaudeSettingsApplied, readClaudeFastModeFacts, readClaudeSettingsEffort } from './claude-structured-session-options' @@ -29,10 +31,6 @@ import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - admitClaudeStartFastMode, - applyClaudeStartFastMode -} from './claude-structured-start-fast-mode' export type ClaudeOpenFacts = Pick< StructuredAgentSessionProviderOpenResult, @@ -59,7 +57,10 @@ export type ClaudeStartupFacts = ClaudeInitializeFacts & { prepared: ReturnType } -/** Settles on the CLI's initialize answer, or on its exit or a refused proof. */ +/** The CLI's initialize answer is a start step like any other: past this the start fails. */ +export const CLAUDE_INITIALIZE_TIMEOUT_MS = 60_000 + +/** Settles on the CLI's initialize answer, or on its exit, a refused proof or the bound. */ export async function readClaudeStartupFacts(input: { connection: ClaudeStreamJsonConnection initProof: ClaudeInitProof @@ -70,7 +71,7 @@ export async function readClaudeStartupFacts(input: { requestTimeoutMs: number | undefined emit: (event: ClaudeStructuredSessionEvent) => void }): Promise { - // The CLI's first answer has no request deadline of its own; the reads after it do. + let timer: ReturnType | undefined const initialization = await Promise.race([ input.connection .initializationResult() @@ -86,8 +87,18 @@ export async function readClaudeStartupFacts(input: { } return result }), - input.initProof.promise.then(() => new Promise(() => {})) - ]) + input.initProof.promise.then(() => new Promise(() => {})), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new Error(`claude did not answer initialize within ${CLAUDE_INITIALIZE_TIMEOUT_MS}ms`) + ), + CLAUDE_INITIALIZE_TIMEOUT_MS + ) + timer.unref?.() + }) + ]).finally(() => clearTimeout(timer)) if (input.connection.closed) { throw new Error('claude session closed before startup completed') } @@ -109,6 +120,13 @@ function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitial const { init, initialization } = facts // A turn's own init frame may already have reported the running model. if (init?.model && session.reportedOptions.model === undefined) { + if (session.optionMutationSequence === 0) { + session.reusableOptions.set('model', init.model) + } else if (session.reusableOptions.get('model') !== init.model) { + session.reusableOptions.delete('model') + session.reusableOptions.delete('effort') + session.reusableOptions.delete('fastMode') + } session.reportedOptions.model = init.model session.reportedModelMutation = session.optionMutationSequence } @@ -132,6 +150,13 @@ function applyClaudeSettingsFacts( const { settings, prepared } = facts const effort = readClaudeSettingsEffort(settings) const published = claudeStructuredSessionPublicationOptions(prepared) + if (!writtenSinceRead) { + const applied = readClaudeSettingsApplied(settings) + session.configuredDefaults = { + ...(!session.options.has('model') && applied?.model ? { model: applied.model } : {}), + ...(!session.options.has('effort') && applied?.effort ? { effort: applied.effort } : {}) + } + } observeClaudeSettingsApplied(session, settings) if (published.fastModePerSessionOptIn !== null) { session.fastModePerSessionOptIn = published.fastModePerSessionOptIn @@ -140,6 +165,7 @@ function applyClaudeSettingsFacts( session.events?.publish() return } + observeClaudeSettingsExecutionEffort(session, settings) // The readback vouches for a value the child was launched with only when it reports that value. const agrees = (key: string, reported: string): boolean => !session.options.has(key) || session.options.get(key) === reported @@ -155,6 +181,7 @@ function applyClaudeSettingsFacts( session.confirmedOptions.add('effort') } if (published.fastMode !== null) { + session.reusableOptions.set('fastMode', String(published.fastMode)) session.reportedOptions.fastMode = published.fastMode if (agrees('fastMode', String(published.fastMode))) { session.confirmedOptions.add('fastMode') @@ -245,7 +272,6 @@ async function settleClaudeStartupSettings( input.diagnose(claudeAuthDiagnostic(facts.initialization, facts.init, settings)) const writtenSinceRead = sequence !== session.optionMutationSequence applyClaudeSettingsFacts(session, facts, writtenSinceRead) - const startFastMode = writtenSinceRead ? null : admitClaudeStartFastMode(session, facts) input.report({ type: 'options-reported', // Open already returned the initialize listing to the host. @@ -254,7 +280,4 @@ async function settleClaudeStartupSettings( // The one report of what the config resolved: the readback just applied it. ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)) }) - if (startFastMode !== null) { - void applyClaudeStartFastMode(session, facts, startFastMode) - } } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 44895a4b027..a2913e1503c 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -168,6 +168,8 @@ export type ClaudeSession = { /** What `get_settings` says the next request will send, after Claude's own env and settings * precedence: the lowest-ranked answer, unconfirmed until a turn reports it. */ appliedOptions?: { model?: string; effort?: string } + /** CLI settings resolved before a turn changed them, when no launch override hid that value. */ + configuredDefaults?: { model?: string; effort?: string } fastModeState?: AgentSessionFastModeState fastModeDisabledReason?: string fastModePerSessionOptIn?: boolean @@ -176,10 +178,13 @@ export type ClaudeSession = { reportedModelMutation: number /** Options whose recorded value the provider reported, not merely accepted. */ confirmedOptions: Set - restoreSkippedOptions: Set + /** Value-bound execution evidence, revoked before any possible write. */ + reusableOptions: Map /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null - /** The launch left the saved Fast out, so the start applies it once the settings are read. */ + /** A launched model the CLI answered `model_not_found` for; reported until a new pick. */ + retiredModel?: string + /** Fast was omitted from launch settings and needs confirmation at message input. */ fastModeAtStart: boolean /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ capabilities: readonly string[] diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts index d155d423c95..49731ee5168 100644 --- a/src/main/claude/claude-structured-spawn-options.test.ts +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -44,7 +44,6 @@ describe('a Claude chat launched with its saved options', () => { effort: 'high', fastMode: 'true' }) - expect(spawn.skipped).toEqual([]) }) // A resolved id, a model newer than any listing Orca holds, a retired one: the CLI's own answer @@ -55,7 +54,6 @@ describe('a Claude chat launched with its saved options', () => { const spawn = launched({ model, effort: 'xhigh' }) expect(spawn.sdkOptions).toMatchObject({ model, effort: 'xhigh' }) - expect(spawn.skipped).toEqual([]) } ) @@ -64,7 +62,6 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).not.toHaveProperty('effort') expect(spawn.sdkOptions).not.toHaveProperty('settings') - expect(spawn.skipped).toEqual(['effort', 'fastMode']) }) it('keeps a saved Fast on for a new conversation, for its start to apply', () => { @@ -73,7 +70,6 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).not.toHaveProperty('settings') expect(spawn.options.get('fastMode')).toBe('true') expect(spawn.fastModeAtStart).toBe(true) - expect(spawn.skipped).toEqual([]) }) it('passes a saved Fast off to a new conversation', () => { @@ -128,7 +124,6 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).toEqual(FULL_ACCESS_LAUNCH) expect(spawn.options.size).toBe(0) - expect(spawn.skipped).toEqual([]) } ) }) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index b86b4ebfba5..da5417039e7 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -1,6 +1,4 @@ -// A chat's saved options as the Claude child's launch options. The child starts already running -// them, so its first message can be written at once instead of waiting for initialize to answer and -// a control request to apply each one. +// Saved choices seed the CLI launch; message choices are applied again before input. import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' @@ -39,8 +37,6 @@ export type ClaudeStructuredSpawnOptions = { sdkOptions: ClaudeStructuredSdkOptions /** The chat's options as the session holds them: what was launched, and a Fast the start applies. */ options: Map - /** Saved options left out: the provider's own value wins and is re-persisted. */ - skipped: readonly string[] /** A saved Fast the launch does not carry, which the start applies. */ fastModeAtStart: boolean } @@ -56,7 +52,6 @@ export function claudeStructuredSpawnOptions(input: { }): ClaudeStructuredSpawnOptions { const saved = restoredClaudeStructuredSessionOptions(input.saved) const options = new Map() - const skipped: string[] = [] let sdkOptions: ClaudeStructuredSdkOptions = { ...input.launch.options } let fastModeAtStart = false const model = saved.get('model') @@ -69,20 +64,15 @@ export function claudeStructuredSpawnOptions(input: { if (isEffortLevel(effort)) { options.set('effort', effort) sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'effort'), effort } - } else { - skipped.push('effort') } } const fastMode = saved.get('fastMode') if (fastMode !== undefined) { const decoded = decodeStructuredAgentSessionOptionValue('fastMode', fastMode) - if (typeof decoded !== 'boolean') { - skipped.push('fastMode') - } else { + if (typeof decoded === 'boolean') { options.set('fastMode', fastMode) - // A new CLI session may opt in to Fast per session, which only its settings say, and the - // agent Arguments' own `--settings` would be replaced by one carrying Fast: either way the - // start applies the saved Fast once it has read them (`applyClaudeStartFastMode`). + // A new CLI session may opt in to Fast per session; the first message applies a saved Fast + // when launch settings cannot carry it. if ( (!decoded || input.launch.resumesTranscript) && !Object.hasOwn(sdkOptions.extraArgs ?? {}, 'settings') @@ -93,22 +83,16 @@ export function claudeStructuredSpawnOptions(input: { } } } - return { sdkOptions, options, skipped, fastModeAtStart } + return { sdkOptions, options, fastModeAtStart } } /** The published session takes on what its child was launched with. */ export function adoptClaudeStructuredSpawnOptions( - session: Pick< - ClaudeSession, - 'restoreSkippedOptions' | 'translator' | 'launchedModel' | 'fastModeAtStart' - >, - spawn: Pick + session: Pick, + spawn: Pick ): void { session.launchedModel = spawn.options.get('model') ?? null session.fastModeAtStart = spawn.fastModeAtStart - for (const key of spawn.skipped) { - session.restoreSkippedOptions.add(key) - } const model = spawn.options.get('model') if (model !== undefined) { session.translator?.modelWritten(model) diff --git a/src/main/claude/claude-structured-start-fast-mode.test.ts b/src/main/claude/claude-structured-start-fast-mode.test.ts index 24057e865fc..16f9693f011 100644 --- a/src/main/claude/claude-structured-start-fast-mode.test.ts +++ b/src/main/claude/claude-structured-start-fast-mode.test.ts @@ -1,11 +1,23 @@ // A saved Fast the launch leaves out beside the agent Arguments' own `--settings`, applied by the -// start once the child answers. +// message input once the child answers. import { expect, it, vi } from 'vitest' -import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support' +import { + adapterFor, + fakeClaude, + identityFor, + claudeOpened, + USER_MESSAGE +} from './claude-structured-session-test-support' -it("applies a resumed conversation's saved Fast off after start beside the Arguments' settings", async () => { +it("applies a resumed conversation's saved Fast off before message input beside the Arguments' settings", async () => { + let fastMode = true const claude = fakeClaude({ + routes: { + apply_flag_settings: () => { + fastMode = false + } + }, settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } }) const adapter = adapterFor(claude, { @@ -25,8 +37,23 @@ it("applies a resumed conversation's saved Fast off after start beside the Argum expect(claude.connections[0].launch.options.extraArgs).toEqual({ settings: '/repo/claude.json' }) + await claudeOpened(adapter, 'session-1') + claude.connections[0].getSettings = async () => ({ + effective: { fastMode, fastModePerSessionOptIn: false } + }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'message-fast', + body: USER_MESSAGE, + executionOptions: { fastMode: 'false' }, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'admitted' }) await vi.waitFor(() => - expect(claude.connections[0].calls.at(-1)).toEqual({ + expect( + claude.connections[0].calls.find((call) => call.subtype === 'apply_flag_settings') + ).toEqual({ subtype: 'apply_flag_settings', params: { settings: { fastMode: false } } }) diff --git a/src/main/claude/claude-structured-start-fast-mode.ts b/src/main/claude/claude-structured-start-fast-mode.ts deleted file mode 100644 index 4650cf897ad..00000000000 --- a/src/main/claude/claude-structured-start-fast-mode.ts +++ /dev/null @@ -1,70 +0,0 @@ -// A saved Fast the Claude child was launched without, applied once its start has read the settings -// that decide whether it may run. - -import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' -import { readClaudeModels } from './claude-structured-init-proof' -import { listedModels } from './claude-structured-model-catalog' -import { claudeModelFastModeSupport } from './claude-structured-session-options' -import type { ClaudeStartupFacts } from './claude-structured-session-startup' -import type { ClaudeSession } from './claude-structured-session-state' - -/** A saved Fast the launch left out (`fastModeAtStart`): on for a new conversation, whose settings - * may opt in to it per session, or either value beside the agent Arguments' own `--settings`. - * Those settings now read: a new conversation's opt-in drops it, as before, and so do the guards a - * live Fast write takes. The value still to be applied, or null. */ -export function admitClaudeStartFastMode( - session: ClaudeSession, - facts: ClaudeStartupFacts -): string | null { - const fastMode = session.options.get('fastMode') - if (!session.fastModeAtStart || fastMode === undefined) { - return null - } - if (fastMode !== 'true') { - return fastMode - } - if (!facts.resumesTranscript && facts.prepared.fastModePerSessionOptIn === true) { - session.options.delete('fastMode') - return null - } - // Over the listing this start already holds. - const listed = listedModels({ models: readClaudeModels(facts.initialization) }) - const blocked = - session.fastModeDisabledReason !== undefined && - !['preference', 'sdk_opt_in_required'].includes(session.fastModeDisabledReason) - if ( - blocked || - (listed.length > 0 && claudeModelFastModeSupport(session, listed).supported !== true) - ) { - session.options.delete('fastMode') - session.restoreSkippedOptions.add('fastMode') - return null - } - return fastMode -} - -/** Applies a saved Fast the launch left out after open, so nothing waits on it. A refusal - * drops the child's value; silence keeps it wanted and - * unconfirmed. A write the user made meanwhile owns the option. */ -export async function applyClaudeStartFastMode( - session: ClaudeSession, - facts: ClaudeStartupFacts, - fastMode: string -): Promise { - const sequence = session.optionMutationSequence - try { - await session.connection.applyFlagSettings( - { fastMode: fastMode === 'true' }, - { timeoutMs: facts.requestTimeoutMs } - ) - } catch (error) { - if (sequence !== session.optionMutationSequence) { - return - } - session.confirmedOptions.delete('fastMode') - if (error instanceof ClaudeControlRequestError) { - session.options.delete('fastMode') - session.restoreSkippedOptions.add('fastMode') - } - } -} diff --git a/src/main/codex/__fixtures__/codex-connection-app-server.ts b/src/main/codex/__fixtures__/codex-connection-app-server.ts new file mode 100644 index 00000000000..f14a91b6925 --- /dev/null +++ b/src/main/codex/__fixtures__/codex-connection-app-server.ts @@ -0,0 +1,30 @@ +export const FAKE_APP_SERVER = String.raw` + const readline = require('node:readline') + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') return send({ id: message.id, result: {} }) + if (message.method === 'test/env') { + return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } }) + } + if (message.method === 'test/cwd') { + return send({ id: message.id, result: { cwd: process.cwd() } }) + } + if (message.method === 'test/notify') { + send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } }) + return send({ id: message.id, result: {} }) + } + if (message.method === 'test/ask') { + return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } }) + } + if (message.method === 'test/refuse') { + return send({ id: message.id, error: { code: -32602, message: 'bad params' } }) + } + if (message.method === 'test/missing') { + return send({ id: message.id, error: { code: -32601, message: 'method not found' } }) + } + if (message.id === 99) { + return send({ method: 'test/answered', params: message }) + } + }) +` diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index 599ab16bf85..29f4f62de25 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -13,8 +13,6 @@ export type CodexAppServerConnectionHandlers = { onExit?: (error: Error, exit?: { expected: boolean }) => void /** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */ onSpawned?: (pid: number) => Promise - /** Any stdout or stderr chunk from the child. */ - onOutput?: () => void } export type CodexAppServerConnection = { diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 5090001308c..a7065e1aad8 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -1,3 +1,4 @@ +import { FAKE_APP_SERVER } from './__fixtures__/codex-connection-app-server' import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import type { EventEmitter } from 'node:events' import { realpathSync } from 'node:fs' @@ -37,36 +38,6 @@ afterEach(() => { * a stub, but it is the only thing that proves the spawn, the environment, and * both traffic directions actually work end to end. */ -const FAKE_APP_SERVER = String.raw` - const readline = require('node:readline') - const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') - readline.createInterface({ input: process.stdin }).on('line', (line) => { - const message = JSON.parse(line) - if (message.method === 'initialize') return send({ id: message.id, result: {} }) - if (message.method === 'test/env') { - return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } }) - } - if (message.method === 'test/cwd') { - return send({ id: message.id, result: { cwd: process.cwd() } }) - } - if (message.method === 'test/notify') { - send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } }) - return send({ id: message.id, result: {} }) - } - if (message.method === 'test/ask') { - return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } }) - } - if (message.method === 'test/refuse') { - return send({ id: message.id, error: { code: -32602, message: 'bad params' } }) - } - if (message.method === 'test/missing') { - return send({ id: message.id, error: { code: -32601, message: 'method not found' } }) - } - if (message.id === 99) { - return send({ method: 'test/answered', params: message }) - } - }) -` async function openFakeServer( handlers: CodexAppServerConnectionHandlers = {}, diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 17ad6e85795..8e57e4cea38 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -36,20 +36,16 @@ export type CodexAppServerLaunch = ProviderProcessLaunch const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 -/** - * Spawns `codex app-server`, completes the initialize handshake, and returns a - * connection that stays open until `close()`. Rejects — after reaping the child - * — when the handshake cannot complete. - */ -export async function openCodexAppServerConnection( +/** Builds the transport so an owner can record its process before initialization. */ +async function connectCodexAppServer( launch: CodexAppServerLaunch, handlers: CodexAppServerConnectionHandlers = {}, - spawnImpl: PipedProcessSpawner = spawnProcess + spawnImpl: PipedProcessSpawner, + initialize: boolean ): Promise { const managed = spawnManagedProviderProcess(launch, { spawnImpl, - site: 'codex-app-server-teardown', - ...(handlers.onOutput ? { onOutput: handlers.onOutput } : {}) + site: 'codex-app-server-teardown' }) const { child } = managed @@ -234,8 +230,10 @@ export async function openCodexAppServerConnection( if (child.pid !== undefined) { await handlers.onSpawned?.(child.pid) } - handshaking = true - await initializeCodexAppServerConnection(connection) + if (initialize) { + handshaking = true + await initializeCodexAppServerConnection(connection) + } } catch (error) { if ((await close()) !== true) { throw new CodexAppServerHandshakeExitUnprovenError(connection, error) @@ -248,3 +246,20 @@ export async function openCodexAppServerConnection( } return connection } + +/** Publishes the transport after its process identity is recorded, before initialization. */ +export function spawnCodexAppServerConnection( + launch: CodexAppServerLaunch, + handlers: CodexAppServerConnectionHandlers = {}, + spawnImpl: PipedProcessSpawner = spawnProcess +): Promise { + return connectCodexAppServer(launch, handlers, spawnImpl, false) +} + +export function openCodexAppServerConnection( + launch: CodexAppServerLaunch, + handlers: CodexAppServerConnectionHandlers = {}, + spawnImpl: PipedProcessSpawner = spawnProcess +): Promise { + return connectCodexAppServer(launch, handlers, spawnImpl, true) +} diff --git a/src/main/codex/codex-background-terminals.test.ts b/src/main/codex/codex-background-terminals.test.ts index 818c8d2a6a0..c2cfb8deb24 100644 --- a/src/main/codex/codex-background-terminals.test.ts +++ b/src/main/codex/codex-background-terminals.test.ts @@ -14,6 +14,7 @@ import { terminateCodexBackgroundTerminals } from './codex-background-terminals' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor, @@ -93,7 +94,7 @@ function commandItem(status: 'inProgress' | 'completed') { /** A turn that starts a dev server and ends with it still running. */ async function backgroundedDevServer(codex: ReturnType): Promise { - await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) codex.notify('turn/started', { threadId: THREAD_ID, turn: { id: TURN_ID, status: 'inProgress' } }) codex.notify('item/started', { threadId: THREAD_ID, @@ -226,7 +227,7 @@ describe('Codex background-command Stop', () => { return { data: [], nextCursor: null } } }) - await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) codex.notify('turn/started', { threadId: THREAD_ID, turn: { id: TURN_ID, status: 'inProgress' } diff --git a/src/main/codex/codex-collab-call-frames-fixture.ts b/src/main/codex/codex-collab-call-frames-fixture.ts index 8926e46dcca..acadb0b972c 100644 --- a/src/main/codex/codex-collab-call-frames-fixture.ts +++ b/src/main/codex/codex-collab-call-frames-fixture.ts @@ -1,9 +1,14 @@ +import { + acquireReadyCodexForTest, + fakeCodex, + identityFor, + THREAD_ID +} from './codex-structured-session-adapter-fixture' // Default-mode collab frames as a live session sent them, and the journal rows the real adapter // publishes for them: what a client reads. import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' // Shapes as a live default-mode session sent them (codex-cli 0.157); only the sender is remapped. @@ -114,7 +119,7 @@ export async function publishedRows(frames: Frame[]): Promise {}, publish: () => {} } - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-configured-launch-defaults.ts b/src/main/codex/codex-configured-launch-defaults.ts index 3ccc9f5dcb0..b8add319b8a 100644 --- a/src/main/codex/codex-configured-launch-defaults.ts +++ b/src/main/codex/codex-configured-launch-defaults.ts @@ -4,7 +4,12 @@ import type { CodexAppServerConnection } from './codex-app-server-connection' // Why short: config/read is a local file read; a slow answer must not hold the listing. const CONFIG_READ_TIMEOUT_MS = 3_000 -type CodexConfiguredLaunchDefaults = { model: string | null; effort: string | null } +type CodexConfiguredLaunchDefaults = { + model: string | null + effort: string | null + personality?: string + serviceTier?: string +} function configuredText(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null @@ -28,7 +33,11 @@ export async function readCodexConfiguredLaunchDefaults( if (typeof config !== 'object' || config === null) { return null } + const personality = 'personality' in config ? configuredText(config.personality) : null + const serviceTier = 'service_tier' in config ? configuredText(config.service_tier) : null return { + ...(personality ? { personality } : {}), + ...(serviceTier ? { serviceTier } : {}), model: 'model' in config ? configuredText(config.model) : null, effort: 'model_reasoning_effort' in config ? configuredText(config.model_reasoning_effort) : null diff --git a/src/main/codex/codex-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts index 0b58dd84630..3086eb6f5a0 100644 --- a/src/main/codex/codex-default-mode-subagents.test.ts +++ b/src/main/codex/codex-default-mode-subagents.test.ts @@ -1,3 +1,9 @@ +import { + acquireReadyCodexForTest, + fakeCodex, + identityFor, + THREAD_ID +} from './codex-structured-session-adapter-fixture' // Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` items that // spawn, message, wait on or close it. These frames, through the real adapter, must register that // helper the same way a `subAgentActivity` does: one child in the strip and the host's records, @@ -11,7 +17,6 @@ import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child import { createAgentStatusStore } from '../../shared/agent-status-store' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexJournalTranslator } from './codex-structured-journal-translation' @@ -161,7 +166,7 @@ async function session() { appendTombstone: () => {}, publish: () => {} } - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index 79e14c6dd94..d4f3477ee84 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -51,6 +51,9 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async ({ fence, spawnToken, events }) => { sink = events ?? null return { diff --git a/src/main/codex/codex-provider-retry-row.test.ts b/src/main/codex/codex-provider-retry-row.test.ts index 7a49e60f279..cb96549d6d6 100644 --- a/src/main/codex/codex-provider-retry-row.test.ts +++ b/src/main/codex/codex-provider-retry-row.test.ts @@ -1,3 +1,9 @@ +import { + acquireReadyCodexForTest, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' // A Codex stream error it is about to retry is a warning row per attempt, never a red row: the // journal keeps every attempt, and the transcript draws only the latest of a run. import { readFileSync } from 'node:fs' @@ -10,7 +16,6 @@ import type { import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' -import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' const THREAD_ID = 'thread-abc' @@ -171,7 +176,7 @@ describe('a Codex stream error it is about to retry', () => { publish: () => undefined } for (const fence of [7, 8]) { - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence, spawnToken: `spawn-${fence}`, diff --git a/src/main/codex/codex-scripted-child.test-fixture.ts b/src/main/codex/codex-scripted-child.test-fixture.ts new file mode 100644 index 00000000000..c8f4fd5d8a2 --- /dev/null +++ b/src/main/codex/codex-scripted-child.test-fixture.ts @@ -0,0 +1,177 @@ +import type { ScriptedAgentChild } from '../runtime/structured-agent-scripted-child.test-fixture' +import type { + CodexAppServerConnection, + openCodexAppServerConnection +} from './codex-app-server-connection' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { CodexAppServerTimeoutError } from './codex-app-server-session' +import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' + +export const CODEX_SCRIPTED_THREAD = 'scripted-codex-thread' +const THREAD = CODEX_SCRIPTED_THREAD + +type ScriptedCodexRoute = (params: Record | undefined) => unknown + +export type ScriptedCodexChild = ScriptedAgentChild & { + /** Every request any spawn received, in order. */ + requests(): readonly { method: string; params?: Record }[] +} + +/** A held answer gives up on the request's own deadline, as the real connection's does (30 s when + * the caller names none). */ +function heldAnswer( + answer: Promise, + method: string, + timeoutMs = 30_000 +): Promise { + let timer: ReturnType | undefined + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new CodexAppServerTimeoutError(`codex app-server ${method} exceeded ${timeoutMs}ms`) + ), + timeoutMs + ) + }) + return Promise.race([answer, timedOut]).finally(() => clearTimeout(timer)) +} + +/** `routes` answers methods the script does not, such as the history reads a rewind makes. */ +export const codexScriptedChild = ( + routes: Readonly> = {} +): ScriptedCodexChild => { + let holdHandshakes = true + let completeTurns = true + let spawned = 0 + let resumed = 0 + let closed = 0 + const prompts: string[] = [] + const requests: { method: string; params?: Record }[] = [] + let newest: ReturnType> | undefined + const open: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { + spawned += 1 + const gate = Promise.withResolvers() + newest = gate + let ended = false + const turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { + if (!ended) { + handlers.onNotification?.(method, params) + } + }) + const connection: CodexAppServerConnection = { + pid: 10_000 + spawned, + get closed() { + return ended + }, + request: async (method, params, options) => { + if (ended) { + throw new Error('scripted Codex child closed') + } + requests.push({ method, ...(params ? { params } : {}) }) + if (method === 'initialize') { + return holdHandshakes ? heldAnswer(gate.promise, method, options?.timeoutMs) : {} + } + const route = routes[method] + if (method === 'thread/resume') { + resumed += 1 + } + if (route) { + return route(params) + } + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD }, model: 'scripted-model' } + } + if (method === 'model/list') { + return { + data: [ + { model: 'scripted-model', isDefault: true, supportedReasoningEfforts: [] }, + { + model: 'scripted-alt-model', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }] + } + ], + nextCursor: null + } + } + if (method === 'turn/start' || method === 'turn/steer') { + const input = params?.input + if (Array.isArray(input)) { + for (const block of input) { + if ( + typeof block === 'object' && + block !== null && + 'text' in block && + typeof block.text === 'string' + ) { + prompts.push(block.text) + } + } + } + const answer = + method === 'turn/start' + ? await turns.routes['turn/start']() + : turns.routes['turn/steer'](params) + if (method === 'turn/start') { + turns.start() + } + if (typeof params?.clientUserMessageId === 'string') { + turns.echo(params.clientUserMessageId) + } + if (completeTurns) { + turns.end('completed') + } + return answer + } + if (method === 'turn/interrupt') { + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + if (!ended) { + ended = true + closed += 1 + gate.reject(new Error('scripted Codex child closed')) + handlers.onExit?.(new Error('scripted Codex child closed'), { expected: true }) + } + return true + } + } + // A gate can be closed before initialization starts or after it has already answered. + void gate.promise.catch(() => {}) + await handlers.onSpawned?.(connection.pid ?? 0) + return connection + } + return { + deps: { + openCodexConnection: open, + readProcessStartTime: async () => 1_700_000_000_000, + resolveCodexCommand: () => '/nonexistent/scripted-codex', + resolveEnvironment: async () => ({}) + }, + get holdHandshakes() { + return holdHandshakes + }, + set holdHandshakes(value) { + holdHandshakes = value + }, + get completeTurns() { + return completeTurns + }, + set completeTurns(value) { + completeTurns = value + }, + releaseHandshake: () => newest?.resolve({}), + failHandshake: (message) => + newest?.reject(new CodexAppServerRequestError('initialize', -32603, message, message)), + prompts: () => prompts, + requests: () => requests, + spawns: () => spawned, + resumes: () => resumed, + closes: () => closed + } +} diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 2c762121327..87ea22e0bbe 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -1,3 +1,9 @@ +import { + acquireReadyCodexForTest, + fakeCodex, + identityFor, + THREAD_ID +} from './codex-structured-session-adapter-fixture' // A Codex session's frames, through the real adapter, into the host's child records: the order the // host receives them in, and the parent row they fold to, written out step by step. @@ -24,7 +30,6 @@ import type { } from '../../shared/agent-session-journal-types' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' const parent = makeStructuredAgentStatusSubject( @@ -135,7 +140,7 @@ async function producer() { moment() } } - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-structured-dispatch-boundary.test.ts b/src/main/codex/codex-structured-dispatch-boundary.test.ts index 5ad36626c46..7e92abc0631 100644 --- a/src/main/codex/codex-structured-dispatch-boundary.test.ts +++ b/src/main/codex/codex-structured-dispatch-boundary.test.ts @@ -1,8 +1,12 @@ import { expect, it, vi } from 'vitest' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' import { + type LateSettlement, acquiredCodexAdapter, CODEX_TEST_USER_MESSAGE, + startTurn, + echoUserMessage, + refuseUnroutedSteer, fakeCodexAppServer } from './codex-structured-dispatch-test-support' @@ -28,3 +32,99 @@ it('checks continuation authority before writing turn/start', async () => { await adapter.closeAll() } }) + +it('stops a refused steer before its fallback write', async () => { + const refused = Promise.withResolvers() + const codex = fakeCodexAppServer({ 'turn/steer': () => refused.promise }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'running') + const controller = new AbortController() + const reason = new Error('Stopped before fallback') + const send = (id: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: id, + fence: 7, + body: CODEX_TEST_USER_MESSAGE, + signal: controller.signal + }) + const sending = send('stopped') + await vi.waitFor(() => + expect(connection.calls.some((call) => call.method === 'turn/steer')).toBe(true) + ) + controller.abort(reason) + try { + refuseUnroutedSteer(undefined) + } catch (error) { + refused.reject(error) + } + await expect(sending).rejects.toBe(reason) + expect(connection.calls.some((call) => call.method === 'turn/start')).toBe(false) + echoUserMessage(connection, { turnId: 'running', itemId: 'stopped', clientId: 'stopped' }) + expect(settlements).toEqual([]) + await adapter.closeAll() +}) + +it('abandons only the stopped message while waiting for a previous turn to open', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'opening' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + const dispatch = (id: string, signal?: AbortSignal) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: id, + fence: 7, + body: CODEX_TEST_USER_MESSAGE, + signal + }) + await expect(dispatch('first')).resolves.toEqual({ state: 'admitted' }) + const controller = new AbortController() + const stopped = dispatch('waiting', controller.signal) + await new Promise((resolve) => setTimeout(resolve, 10)) + const reason = new Error('Stopped local wait') + controller.abort(reason) + await expect(stopped).rejects.toBe(reason) + expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(1) + startTurn(connection, 'opening') + echoUserMessage(connection, { turnId: 'opening', itemId: 'ignored', clientId: 'waiting' }) + expect(settlements).toEqual([]) + await adapter.closeAll() +}) + +it.each(['accepted', 'uncertain'] as const)( + 'retains the %s outcome of a steer written before Stop', + async (outcome) => { + const answer = Promise.withResolvers() + const codex = fakeCodexAppServer({ 'turn/steer': () => answer.promise }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'running') + const controller = new AbortController() + const sent = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'written', + fence: 7, + body: CODEX_TEST_USER_MESSAGE, + signal: controller.signal + }) + await vi.waitFor(() => + expect(connection.calls.some((call) => call.method === 'turn/steer')).toBe(true) + ) + controller.abort(new Error('Stop after write')) + if (outcome === 'accepted') { + answer.resolve({ turnId: 'running' }) + await expect(sent).resolves.toEqual({ state: 'admitted' }) + } else { + const lost = new Error('Reply lost after write') + answer.reject(lost) + await expect(sent).rejects.toBe(lost) + } + echoUserMessage(connection, { turnId: 'running', itemId: 'written', clientId: 'written' }) + expect(settlements.map((entry) => entry.clientMessageId)).toEqual(['written']) + await adapter.closeAll() + } +) diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 9ac0f7f31bd..8c48c86e33c 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { expect, vi } from 'vitest' import type { AgentJournalMessageItem, @@ -117,7 +118,7 @@ export async function acquiredCodexAdapter(input: { agent: 'codex', providerHandle: codexProviderHandle(CODEX_TEST_THREAD_ID) } - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity, fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 56758abac39..eebbb720477 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -200,15 +200,15 @@ export function createCodexJournalTranslator( return suppressionAdmission.accepted ? prompts.handle(event) : suppressionAdmission } if (event.type === 'server-request') { - return genericFrames.appendUnhandled( - `request:${event.method}`, - event.params, - event.threadId - ) + const requestKey = `request:${event.method}` + return genericFrames.appendUnhandled(requestKey, event.params, event.threadId) } if (event.type === 'provider-frame') { return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId) } + if (event.type !== 'notification') { + return CODEX_JOURNAL_ADMITTED + } if (event.method === 'turn/started' || event.method === 'turn/completed') { return event.method === 'turn/started' ? turnBoundaries.start(event) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 5514f9c7c4b..d9b10dc4186 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -1,7 +1,7 @@ // How a durable session record becomes a Codex process launch. // // Every input is read back from the record the store already made durable, not -// from the call that triggered the acquire. A client that attaches twice must +// from the call that triggered spawn. A client that attaches twice must // land in the same working directory under the same account home, and a resume // must name the thread this session actually proved — never one a caller asks // for, which is how a resume becomes a fork wearing a resume's name. diff --git a/src/main/codex/codex-structured-live-provider-process.test.ts b/src/main/codex/codex-structured-live-provider-process.test.ts index f6163cf0558..d9076548228 100644 --- a/src/main/codex/codex-structured-live-provider-process.test.ts +++ b/src/main/codex/codex-structured-live-provider-process.test.ts @@ -1,11 +1,16 @@ +import { + acquireReadyCodexForTest, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' import { describe, expect, it } from 'vitest' -import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' describe('Codex adapter liveness for lease renewal', () => { it('holds its acquisition until the connection reports the root exit', async () => { const codex = fakeCodex() const adapter = adapterFor(codex) - const { acquisitionGeneration } = await adapter.acquire({ + const { acquisitionGeneration } = await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts new file mode 100644 index 00000000000..60203acc664 --- /dev/null +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { optionSession } from './codex-structured-option-session.test-fixture' +import { resolvedCodexMessageOptions } from './codex-structured-message-options' +import { startCodexTurn } from './codex-structured-turn-start' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import type { CodexSession } from './codex-structured-session-state' +import { + acquireReadyCodexForTest, + USER_MESSAGE, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' + +function seedMessageDefaults(session: CodexSession): void { + const store = new AgentModelCatalogStore() + const fingerprint = 'codex-message-defaults' + store.recordSuccess( + fingerprint, + 'codex', + { + origin: 'probe', + models: [ + { + id: 'model-a', + label: 'Model A', + isDefault: false, + defaultEffort: 'low', + serviceTiers: [{ value: 'priority', label: 'Priority' }], + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'model-b', + label: 'Model B', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ] + }, + 'discovery' + ) + session.catalogAccess = { store, fingerprint, accountHomePath: '/test-account' } +} + +describe('Codex captured message choices', () => { + it('uses each message’s choices on turn/start and tracks the native attempted choice', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session = optionSession(request) + session.options.set('model', 'saved-next') + session.options.set('effort', 'medium') + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + for (const [id, model, effort] of [ + ['first', 'model-a', 'high'], + ['second', 'model-b', 'low'] + ]) { + const turnOptions = await resolvedCodexMessageOptions(session, { model, effort }) + await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) + } + session.activeTurnIds = new Set(['turn-1']) + await startCodexTurn(session, { + clientMessageId: 'steer', + body, + turnOptions: { personality: 'friendly', serviceTier: 'priority' } + }) + expect(request.mock.calls.at(-1)?.[0]).toBe('turn/steer') + expect(request.mock.calls.at(-1)?.[1]).not.toHaveProperty('personality') + expect(request.mock.calls.at(-1)?.[1]).not.toHaveProperty('serviceTier') + expect(session.appliedTurnOptions).toEqual({ model: 'model-b', effort: 'low' }) + session.activeTurnIds = new Set() + await startCodexTurn(session, { + clientMessageId: 'next', + body, + turnOptions: { personality: 'friendly', serviceTier: 'priority' } + }) + expect(request.mock.calls.at(-1)?.[1]).toMatchObject({ + personality: 'friendly', + serviceTier: 'priority' + }) + expect( + request.mock.calls + .filter(([method]) => method === 'turn/start') + .map(([, params]) => [params?.model, params?.effort]) + ).toEqual([ + ['model-a', 'high'], + ['model-b', 'low'], + [undefined, undefined] + ]) + expect(Object.fromEntries(session.options)).toEqual({ + personality: 'friendly', + serviceTier: 'priority' + }) + }) + + it('resets an explicit empty choice after a thread launch and earlier turn override', async () => { + const request = vi.fn(async (method: string, _params?: Record) => + method === 'turn/start' + ? { turn: { id: 'opened' } } + : { config: { personality: 'pragmatic', service_tier: 'flex' } } + ) + const session = optionSession(request) + session.reportedOptions = { model: 'model-a', effort: 'high' } + session.options = new Map([ + ['model', 'model-a'], + ['effort', 'high'], + ['personality', 'friendly'], + ['serviceTier', 'priority'] + ]) + seedMessageDefaults(session) + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + + for (const [id, choices] of [ + [ + 'first', + { model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' } + ], + ['second', {}], + ['third', { model: 'model-a' }] + ] as const) { + const turnOptions = await resolvedCodexMessageOptions(session, choices) + await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) + } + + expect( + request.mock.calls.filter(([method]) => method === 'turn/start').map(([, params]) => params) + ).toMatchObject([ + { model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' }, + { model: 'model-b', effort: 'medium', personality: 'pragmatic', serviceTier: 'flex' }, + { model: 'model-a', effort: 'low' } + ]) + expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'config/read', + 'turn/start', + 'turn/start', + 'turn/start' + ]) + expect(Object.fromEntries(session.options)).toEqual({ + model: 'model-a', + effort: 'low' + }) + }) + + it('inherits untouched native tier and personality without a configuration probe', async () => { + const request = vi.fn(async (_method: string) => ({ turn: { id: 'native' } })) + const session = optionSession(request) + session.reportedOptions = { model: 'model-a', serviceTier: 'flex', serviceTierKnown: true } + const turnOptions = await resolvedCodexMessageOptions(session, {}) + await startCodexTurn(session, { clientMessageId: 'native', body: USER_MESSAGE, turnOptions }) + expect(request).toHaveBeenCalledOnce() + expect(request.mock.calls[0]?.[0]).toBe('turn/start') + expect(turnOptions).toEqual({}) + }) + + it('opens from the first captured model even when launch resolution saw a newer saved pick', async () => { + const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'opened' } }) }) + const adapter = adapterFor(codex, { model: 'model-b' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1', + options: { model: 'model-a', effort: 'high' } + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'first', + body: USER_MESSAGE, + executionOptions: { model: 'model-a', effort: 'high' }, + fence: 7 + }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params + ).toMatchObject({ model: 'model-a' }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'turn/start')?.params + ).toMatchObject({ model: 'model-a', effort: 'high' }) + }) + + it('preserves the old host path when no message snapshot was supplied', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'legacy' } + })) + const session = optionSession(request) + session.options.set('model', 'saved-next') + session.options.set('effort', 'high') + const turnOptions = await resolvedCodexMessageOptions(session, undefined) + await startCodexTurn(session, { + clientMessageId: 'legacy', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, + turnOptions + }) + expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' }) + }) + + it('refuses a default reset with no trustworthy default before prompt input', async () => { + const request = vi.fn(async () => ({ turn: { id: 'unexpected' } })) + const session = optionSession(request) + session.options.set('model', 'old-override') + session.options.set('effort', 'high') + await expect(resolvedCodexMessageOptions(session, {})).rejects.toThrow( + 'default model is unavailable' + ) + expect(request).not.toHaveBeenCalled() + }) + + it('restores native options after a known refusal and retains them after an unknown answer', async () => { + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + const known = optionSession( + vi.fn(async () => { + throw new CodexAppServerRequestError('turn/start', -32602, 'invalid model') + }) + ) + known.options.set('model', 'prior') + await expect( + startCodexTurn(known, { clientMessageId: 'known', body, turnOptions: { model: 'attempted' } }) + ).rejects.toThrow('invalid model') + expect(Object.fromEntries(known.options)).toEqual({ model: 'prior' }) + const unknown = optionSession( + vi.fn(async () => { + throw new Error('connection lost after write') + }) + ) + unknown.options.set('model', 'prior') + await expect( + startCodexTurn(unknown, { + clientMessageId: 'unknown', + body, + turnOptions: { model: 'attempted' } + }) + ).rejects.toThrow('connection lost after write') + expect(Object.fromEntries(unknown.options)).toEqual({ model: 'attempted' }) + }) +}) diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts new file mode 100644 index 00000000000..2aee7279dc9 --- /dev/null +++ b/src/main/codex/codex-structured-message-options.ts @@ -0,0 +1,99 @@ +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { readCodexConfiguredLaunchDefaults } from './codex-configured-launch-defaults' +import { codexAcquireCatalogListing } from './codex-structured-catalog-entry' +import { CODEX_DEFAULT_SERVICE_TIER } from './codex-structured-service-tier' +import type { CodexSession } from './codex-structured-session-state' +import { applyCodexStructuredSessionOption } from './codex-structured-session-options' + +/** Resolve one message's choices and baseline without changing the next pick. */ +export async function resolvedCodexMessageOptions( + session: CodexSession, + choices: AgentSessionExecutionOptions | undefined +): Promise> | undefined> { + if (choices === undefined) { + return undefined + } + const listing = codexAcquireCatalogListing(session.catalogAccess) + const listedDefault = listing?.models.find((entry) => entry.isDefault) + const priorModel = session.options.get('model') + const priorEffort = session.options.get('effort') + const model = + choices.model ?? + listedDefault?.id ?? + session.nativeDefaults?.model ?? + (priorModel || priorEffort ? undefined : session.reportedOptions.model) + if (!model && (choices.effort || priorModel || priorEffort)) { + throw new AgentSessionOptionRejectedError(new Error('codex default model is unavailable')) + } + const listedModel = listing?.models.find((entry) => entry.id === model) + const defaultEffort = + listedModel?.defaultEffort ?? + (model === session.nativeDefaults?.model ? session.nativeDefaults?.effort : undefined) + if (choices.effort === undefined && priorEffort && !defaultEffort) { + throw new AgentSessionOptionRejectedError(new Error('codex default effort is unavailable')) + } + // Capture configuration before our first override, never from mutable turn reports. + if ( + (choices.personality && !session.nativeDefaults?.personality) || + ((choices.serviceTier || choices.fastMode) && !session.nativeDefaults?.serviceTier) + ) { + const configured = await readCodexConfiguredLaunchDefaults(session.connection) + session.nativeDefaults = { + ...session.nativeDefaults, + ...(configured?.personality && !session.nativeDefaults?.personality + ? { personality: configured.personality } + : {}), + ...(configured?.serviceTier && !session.nativeDefaults?.serviceTier + ? { serviceTier: configured.serviceTier } + : {}) + } + } + const restored: Record = {} + for (const key of ['personality', 'serviceTier'] as const) { + const applied = session.appliedTurnOptions?.[key] + if ( + choices[key] !== undefined || + (key === 'serviceTier' && choices.fastMode !== undefined) || + !applied + ) { + continue + } + const baseline = session.nativeDefaults?.[key] + if (!baseline) { + throw new AgentSessionOptionRejectedError(new Error(`codex default ${key} is unavailable`)) + } + if (applied !== baseline) { + restored[key] = baseline + } + } + const selected = { + ...session, + options: new Map(Object.entries(restored)), + reportedOptions: { ...session.reportedOptions, effort: undefined } + } + if ( + model && + (choices.model || + priorModel || + priorEffort || + choices.effort || + choices.fastMode === 'true' || + (choices.serviceTier && choices.serviceTier !== CODEX_DEFAULT_SERVICE_TIER)) + ) { + await applyCodexStructuredSessionOption(selected, 'model', model) + if (!defaultEffort && choices.effort === undefined) { + selected.options.delete('effort') + } + } + if (choices.effort === undefined && defaultEffort && (priorEffort || choices.model)) { + await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) + } + for (const key of ['effort', 'personality', 'fastMode', 'serviceTier'] as const) { + const value = choices[key] + if (value !== undefined) { + await applyCodexStructuredSessionOption(selected, key, value) + } + } + return Object.fromEntries(selected.options) +} diff --git a/src/main/codex/codex-structured-option-session.test-fixture.ts b/src/main/codex/codex-structured-option-session.test-fixture.ts new file mode 100644 index 00000000000..7f1146a0d4f --- /dev/null +++ b/src/main/codex/codex-structured-option-session.test-fixture.ts @@ -0,0 +1,31 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' +import type { CodexSession } from './codex-structured-session-state' + +export function optionSession(request: CodexAppServerConnection['request']): CodexSession { + return { + connection: { + pid: 1, + closed: false, + request, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), + ended: false, + fence: 1, + acquisitionGeneration: 'generation-1', + threadId: 'thread-1', + prompts: new CodexAcquisitionWindow().prompts, + options: new Map(), + reportedOptions: { model: 'gpt-live', effort: 'high' }, + dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), + translator: null + } +} diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index a0b2dc4146e..6b973289775 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -1,3 +1,11 @@ +import { + acquireReadyCodexForTest, + THREAD_ID, + acquired, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' @@ -8,13 +16,6 @@ import type { import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexAppServerRequestError } from './codex-app-server-connection' -import { - THREAD_ID, - acquired, - adapterFor, - fakeCodex, - identityFor -} from './codex-structured-session-adapter-fixture' import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' @@ -231,7 +232,11 @@ describe('Codex live prompt ownership', () => { ).rejects.toThrow(/no longer waiting/) await adapter.closeSession('session-1') - await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 8, + spawnToken: 'spawn-10' + }) await expect( adapter.answerPrompt({ sessionId: 'session-1', @@ -278,7 +283,7 @@ describe('Codex live prompt ownership', () => { 'turn/interrupt': () => completeTurn(codex, 'thread-child', 'child-turn') }) const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' @@ -335,7 +340,7 @@ describe('Codex live prompt ownership', () => { }) const recorded = lifecycleRecorder() const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -398,7 +403,7 @@ describe('Codex live prompt ownership', () => { }) const recorded = lifecycleRecorder() const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -448,7 +453,7 @@ describe('Codex live prompt ownership', () => { const codex = fakeCodex() const recorded = lifecycleRecorder() const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -517,7 +522,7 @@ describe('Codex live prompt ownership', () => { const codex = fakeCodex() const recorded = lifecycleRecorder(false) const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -559,7 +564,7 @@ describe('Codex live prompt ownership', () => { } }) const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -640,7 +645,11 @@ describe('Codex live prompt ownership', () => { } expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) - await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 8, + spawnToken: 'spawn-10' + }) await expect( adapter.cancelTurn({ sessionId: 'session-1', diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts deleted file mode 100644 index d311785d817..00000000000 --- a/src/main/codex/codex-structured-question-order.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -// A Codex ask with several questions, driven from the real host journal through -// the client's session reducer to the rows the transcript list draws. -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalRenderItem -} from '../../shared/agent-session-journal-types' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession, - type StructuredAgentSessionState -} from '../../shared/structured-agent-session-reducer' -import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' -import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' -import { CodexJournalPrompts } from './codex-structured-journal-prompts' -import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { - projectStructuredQuestionMessages, - structuredQuestionTranscript -} from '../../shared/structured-agent-question-projection' -import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -type Asked = readonly { id: string; question: string }[] - -// Codex's question ids are the model's own words, so their text order is not -// the order it asked in. These two asks spell the two orders live QA saw. -const ASKED: Asked = [ - { id: 'scope', question: 'Which files are in scope?' }, - { id: 'priority', question: 'What matters most?' }, - { id: 'deadline', question: 'When is it due?' } -] -const ASKED_OUT_OF_ORDER: Asked = [ - { id: 'format', question: 'Which format?' }, - { id: 'audience', question: 'Who reads it?' }, - { id: 'length', question: 'How long?' } -] - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let sink: StructuredAgentSessionEventSink | null -let client: StructuredAgentSessionState -let clock: number - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-codex-question-order-')) - resetHostTestOperationIds() - sink = null - clock = HOST_TEST_NOW - const adapter: StructuredAgentSessionAdapter = { - acquire: vi.fn(async ({ fence, events }) => { - sink = events ?? null - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: HOST_TEST_NOW - } - } - }), - releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async ({ commit }) => commit()), - setOption: vi.fn(async () => undefined) - } - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - // Every write lands on its own millisecond, as it does live. - now: () => (clock += 1) - }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - if (!page.ok) { - throw new Error('no history page') - } - client = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { - type: 'history-page', - page: page.page - }) - host.subscribe({ - id: 'client', - sessionId: SESSION, - cursor: page.page.liveCursor ?? page.page.window.nextCursor, - emit: (event) => { - client = reduceStructuredAgentSession(client, { type: 'event', event }) - } - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function codexPrompts(): CodexJournalPrompts { - if (!sink) { - throw new Error('session was never acquired') - } - return new CodexJournalPrompts( - { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, - () => null, - () => 'turn-1' - ) -} - -async function ask(prompts: CodexJournalPrompts, asked: Asked = ASKED): Promise { - prompts.handle({ - threadId: THREAD, - method: CODEX_USER_INPUT_METHOD, - codexItemId: 'codex-item-1', - promptKey: '7', - params: { - threadId: THREAD, - turnId: 'turn-1', - questions: asked.map((question) => ({ - ...question, - options: [ - { label: 'Yes', description: '' }, - { label: 'No', description: '' } - ] - })) - } - }) - await host.flushStreamedEvents(SESSION) -} - -function questionItem(question: string): AgentJournalRenderItem { - const item = client.items.find( - (candidate) => candidate.body.kind === 'question' && candidate.body.question === question - ) - if (!item || item.body.kind !== 'question') { - throw new Error(`no journal item for ${question}`) - } - return item -} - -async function answer(question: string): Promise { - const item = questionItem(question) - if (item.body.kind !== 'question') { - return - } - const fields = { - itemId: item.itemId, - expectedRevision: item.revision, - optionId: item.body.options[0]!.id - } - const result = await host.respondToPrompt(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.respondTo:question', - sessionId: SESSION, - fields - }) - }, - kind: 'question', - ...fields - }) - expect(result.ok).toBe(true) - await host.flushStreamedEvents(SESSION) -} - -/** The prompt rows the transcript list draws, top to bottom, as the questions each one shows. */ -function drawnPromptRows(): string[][] { - const { receipts } = structuredQuestionTranscript(client.items) - // The desktop list's projection: its comparator adds only a rank for rows the host never writes. - const rows = projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages( - client.items, - [], - client.submissions, - projectStructuredQuestionMessages - ) - ) - return rows.flatMap((row) => { - const prompt = receipts.get(row.id) - if (!prompt || prompt.kind !== 'question') { - return [] - } - const questions = prompt.questions?.length ? prompt.questions : [prompt] - return [questions.map(({ question }) => `${question} (${prompt.resolution.state})`)] - }) -} - -describe('a Codex ask with several questions', () => { - it('keeps the pending rest of the ask below the question already answered', async () => { - await ask(codexPrompts()) - await answer(ASKED[0]!.question) - - expect(drawnPromptRows()).toEqual([ - ['Which files are in scope? (resolved)'], - ['What matters most? (pending)', 'When is it due? (pending)'] - ]) - }) - - it('draws every answered question in the order Codex asked it', async () => { - await ask(codexPrompts()) - for (const { question } of ASKED) { - await answer(question) - } - - expect(drawnPromptRows()).toEqual([ - ['Which files are in scope? (resolved)'], - ['What matters most? (resolved)'], - ['When is it due? (resolved)'] - ]) - // Mobile draws the shared projection in journal order, one row per question. - expect( - projectStructuredAgentSessionMessages(client.items, [], client.submissions).map( - ({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null) - ) - ).toEqual(ASKED.map(({ question }) => question)) - }) - - it('draws a cancelled ask in the order Codex asked it', async () => { - const prompts = codexPrompts() - await ask(prompts, ASKED_OUT_OF_ORDER) - prompts.cancel(questionItem(ASKED_OUT_OF_ORDER[0]!.question).itemId) - await host.flushStreamedEvents(SESSION) - - expect(drawnPromptRows()).toEqual([ - ['Which format? (cancelled)'], - ['Who reads it? (cancelled)'], - ['How long? (cancelled)'] - ]) - }) -}) diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index aa85f16d06c..b73d8c8df38 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi, type Mock } from 'vitest' import { + acquireReadyCodexForTest, + codexStartReport, THREAD_ID, USER_MESSAGE, adapterFor, @@ -15,7 +17,6 @@ import { import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' import type { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' -import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' function listing(tier = 'priority') { return { @@ -31,7 +32,7 @@ function listing(tier = 'priority') { } async function acquire(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { - return adapter.acquire({ + return acquireReadyCodexForTest(adapter, { identity: identityFor(sessionId), fence: 7, spawnToken: `spawn-${sessionId}`, @@ -61,7 +62,7 @@ describe('Codex structured service tier without send-path catalog waits', () => const adapter = adapterFor(codex) await acquire(adapter) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toMatchObject({ + expect(codexStartReport(adapter).reportedOptions).toMatchObject({ serviceTier: 'priority' }) await send(adapter, 'first') @@ -190,7 +191,7 @@ describe('Codex structured service tier without send-path catalog waits', () => const codex = fakeCodex() codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-tier') const adapter = adapterFor(codex, { codexHome: '/codex/home', resumeThreadId: THREAD_ID }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-resume', @@ -198,7 +199,7 @@ describe('Codex structured service tier without send-path catalog waits', () => }) expect(codex.connections[0].calls.some((call) => call.method === 'thread/resume')).toBe(true) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-next', serviceTier: 'ultrafast' }) @@ -237,7 +238,11 @@ describe('Codex service tier across clients and rest', () => { 'discovery' ) const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) await expect( adapter.setOption({ sessionId: 'session-1', key: 'fastMode', value: 'true', fence: 7 }) @@ -255,18 +260,4 @@ describe('Codex service tier across clients and rest', () => { expect(CODEX_STRUCTURED_AGENT.restingOptions?.acceptsKey('serviceTier')).toBe(true) expect(CODEX_STRUCTURED_AGENT.restingOptions?.acceptsKey('fastMode')).toBe(false) }) - - it('keeps only the reported tier, not the Fast it implies, on the record', () => { - expect( - nativeSessionOptionsFromReport({ - reported: { model: 'gpt-live', serviceTier: 'priority', fastMode: true }, - restoreSkipped: [], - priorOptions: { fastMode: 'true', serviceTier: 'ultrafast' } - }) - ).toEqual({ model: 'gpt-live', serviceTier: 'priority' }) - // An agent that reports Fast with no tier still keeps it. - expect( - nativeSessionOptionsFromReport({ reported: { fastMode: true }, restoreSkipped: [] }) - ).toEqual({ fastMode: 'true' }) - }) }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 88e30f4d162..9906d7fe990 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -4,7 +4,6 @@ import { isAgentSessionProviderHandleInNamespace } from '../../shared/agent-session-provider-handle-encoding' import { - AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, type AgentSessionAcquisition, type StructuredAgentSessionAcquireInput @@ -13,29 +12,19 @@ import { closeFailedCodexAcquisition, stopSupersededCodexAcquisition } from './codex-structured-acquisition-lifecycle' -import { CodexBackgroundTaskTracker, codexChildWorkSink } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexSessionJournalTranslator } from './codex-structured-session-journal' -import { openCodexAppServerConnection } from './codex-app-server-connection' -import { - codexProviderHandleLink, - codexSpawnedProcessIdentity -} from './codex-structured-owner-identity' -import { openCodexThread } from './codex-structured-thread-open' -import { withCodexVisualsThreadConfig } from './codex-structured-visuals' -import { NATIVE_CHAT_SCRATCH_DIR_ENV } from '../native-chat/native-chat-visuals-delivery' +import { spawnCodexAppServerConnection } from './codex-app-server-connection' +import { codexSpawnedProcessIdentity } from './codex-structured-owner-identity' import { closeCodexPublishedSession, handleCodexSessionExit } from './codex-structured-session-close' -import { restoredCodexSessionOptions } from './codex-structured-session-options' -import { startBackgroundCodexCatalogRefresh } from './codex-structured-background-catalog' -import { codexAcquireCatalogAccess } from './codex-structured-acquire-catalog' -import { reportedCodexThreadOptions } from './codex-structured-service-tier' +import { openCodexStructuredSession } from './codex-structured-session-start' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { assertCodexConnectionOpen, - codexSessionLifecycle, mintCodexAcquisitionGeneration, type CodexAcquisitionRegistry, type CodexAcquisitionAttempt, @@ -93,7 +82,7 @@ export async function acquireCodexStructuredSession(input: { subagentExecutions, prompts: acquisition.prompts }) - const open = deps.openConnection ?? openCodexAppServerConnection + const open = deps.openConnection ?? spawnCodexAppServerConnection const spawnIdentity = codexSpawnedProcessIdentity(acquireInput, deps.readProcessStartTime) try { await stopSupersededCodexAcquisition({ @@ -114,7 +103,7 @@ export async function acquireCodexStructuredSession(input: { throw new Error(`codex app-server for session ${sessionId} could not be stopped`) } acquisitions.assertCurrent(sessionId, attempt) - const launch = await deps + const resolvedLaunch = await deps .resolveLaunch({ identity: acquireInput.identity, spawnToken: acquireInput.spawnToken, @@ -123,6 +112,14 @@ export async function acquireCodexStructuredSession(input: { .catch((error: unknown) => { throw new AgentSessionPreSpawnError(error) }) + const { model: _savedNextModel, ...launchSettings } = resolvedLaunch + const launch = + acquireInput.options === undefined + ? resolvedLaunch + : { + ...launchSettings, + ...(acquireInput.options.model ? { model: acquireInput.options.model } : {}) + } acquisitions.assertCurrent(sessionId, attempt) account = launch.codexHome ? deps.resolveAccountKind?.(launch.codexHome) : undefined const connection = await open( @@ -158,9 +155,35 @@ export async function acquireCodexStructuredSession(input: { Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') ), onSpawned: spawnIdentity.onSpawned, - ...(acquireInput.onOutput ? { onOutput: acquireInput.onOutput } : {}), onExit: (error, exit) => { try { + if (attempt.acquisitionGeneration && !sessions.has(sessionId)) { + attempt.exitProven = true + attempt.exitError = error + attempt.dispose?.() + acquisitions.deleteIfCurrent(sessionId, attempt) + deps.onEvent?.({ + type: 'ended', + sessionId, + fence: acquireInput.fence, + acquisitionGeneration, + reason: error.message, + cause: + attempt.cancelled && attempt.requestedClose !== false + ? 'requested-close' + : 'unexpected-exit', + failure: attempt.cancelled + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', { + detail: providerDiagnosticOf(error) + }), + observedAt: deps.now?.() ?? Date.now() + }) + } + if (sessions.get(sessionId)?.connection === acquisition.connection) { + attempt.exitProven = true + attempt.exitError = error + } handleCodexSessionExit({ sessions, sessionId, @@ -189,88 +212,44 @@ export async function acquireCodexStructuredSession(input: { }) } acquisitions.assertCurrent(sessionId, attempt) - const threadLaunch = await withCodexVisualsThreadConfig(connection, launch, { - sessionId, - ...(deps.logger ? { logger: deps.logger } : {}) - }) - acquisitions.assertCurrent(sessionId, attempt) - const commandEnv = { - ...launch.commandEnv, - ...(launch.visuals ? { [NATIVE_CHAT_SCRATCH_DIR_ENV]: launch.visuals.folder } : {}) - } - const opened = await openCodexThread( - connection, - { ...threadLaunch, commandEnv }, - deps.requestTimeoutMs - ) - acquisitions.assertCurrent(sessionId, attempt) - primaryThreadId = opened.threadId - const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) - if (restoreAdmission && !restoreAdmission.accepted) { - throw AgentSessionAcquisitionRefusal.historyTooLarge( - 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' - ) - } const process = await spawnIdentity.read(connection.pid) acquisitions.assertCurrent(sessionId, attempt) - const acquired: AgentSessionAcquisition = { - process, - link: codexProviderHandleLink({ - threadId: opened.threadId, - ...(opened.supersededThreadId - ? { resumed: false, supersedesThreadId: opened.supersededThreadId } - : { resumed: launch.resumeThreadId !== null }), - fence: acquireInput.fence, - linkId: deps.mintLinkId?.(), - observedAt: deps.now?.() ?? Date.now() - }), - acquisitionGeneration - } assertCodexConnectionOpen(connection, sessionId) - acquisitions.assertCurrent(sessionId, attempt) - const options = restoredCodexSessionOptions(acquireInput.options) - const catalogAccess = codexAcquireCatalogAccess(deps, launch) - acquisitions.assertCurrent(sessionId, attempt) - assertCodexConnectionOpen(connection, sessionId) - acquisitions.deleteIfCurrent(sessionId, attempt) - // Where this session's child work goes: the host's records, after each frame is journaled. - const sink = codexChildWorkSink(sessionId, deps, acquireInput.events) - const session: CodexSession = { - account, - connection, - ...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string), - threadId: opened.threadId, - historyMode: opened.historyMode, - activeTurnIds: new Set(), - abortedTurnIds: new Set(), - prompts: acquisition.prompts, - options, - reportedOptions: reportedCodexThreadOptions(opened), - ...(catalogAccess ? { catalogAccess } : {}), - dispatchEchoes, - translator, - backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions, sink), - forceCloseUnexpected: (reason) => - input.forceCloseUnexpected( - sessionId, - acquireInput.fence, - acquired.acquisitionGeneration as string, - reason - ), - ...(unbindReadingControl ? { unbindReadingControl } : {}) + attempt.acquisitionGeneration = acquisitionGeneration + let disposed = false + attempt.dispose = () => { + if (disposed) { + return + } + disposed = true + unbindReadingControl?.() + acquisition.prompts.clear() + acquisition.drain() + translator?.dispose() } - sessions.set(sessionId, session) - for (const event of acquisition.drain()) { - event() - } - startBackgroundCodexCatalogRefresh({ - session, - sessionId, - sessions, - timeoutMs: deps.requestTimeoutMs, - logger: deps.logger - }) - return acquired + attempt.open = (openInput) => + openCodexStructuredSession( + { + input: acquireInput, + deps, + launch, + account, + sessions, + acquisitions, + attempt, + connection, + translator, + dispatchEchoes, + subagentExecutions, + setPrimaryThreadId: (threadId) => { + primaryThreadId = threadId + }, + forceCloseUnexpected: input.forceCloseUnexpected, + ...(unbindReadingControl ? { unbindReadingControl } : {}) + }, + openInput + ) + return { process, acquisitionGeneration } } catch (error) { if (sessions.get(sessionId)?.connection !== acquisition.connection) { return closeFailedCodexAcquisition({ diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index 6a7c9f2af5a..c759a3f08cd 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -1,3 +1,8 @@ +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionOptionsReportedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -16,6 +21,45 @@ import { import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +const startLinks = new WeakMap< + CodexStructuredSessionAdapter, + Map> +>() +const startReports = new WeakMap< + CodexStructuredSessionAdapter, + Map +>() + +export function codexStartReport(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { + const report = startReports.get(adapter)?.get(sessionId) + if (!report) { + throw new Error(`Codex has not started ${sessionId}`) + } + return report +} + +export function codexStartedLink(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { + return startLinks.get(adapter)?.get(sessionId) +} + +export async function acquireReadyCodexForTest( + adapter: CodexStructuredSessionAdapter, + input: StructuredAgentSessionAcquireInput +) { + const acquired = await adapter.acquire(input) + const result = await adapter.open({ + sessionId: input.identity.sessionId, + fence: input.fence, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) + startReports.get(adapter)?.set(input.identity.sessionId, result) + if (result.link) { + startLinks.get(adapter)?.set(input.identity.sessionId, result.link) + } + return acquired +} + export const THREAD_ID = 'thread-abc' export function identityFor(sessionId: string): AgentSessionJournalIdentity { @@ -81,6 +125,7 @@ export function fakeCodex(routes: Record = {}): { } } connections.push(connection) + await handlers.onSpawned?.(connection.pid ?? 0) return connection } routes['thread/start'] ??= () => ({ @@ -117,7 +162,12 @@ export function adapterFor( deps: Partial = {} ): CodexStructuredSessionAdapter { let acquisitionGeneration = 0 - return new CodexStructuredSessionAdapter({ + const links = new Map>() + const reports = new Map< + string, + StructuredAgentSessionProviderOpenResult | StructuredAgentSessionOptionsReportedEvent + >() + const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ command: 'codex', args: ['app-server'], @@ -126,13 +176,23 @@ export function adapterFor( resumeThreadId: null, ...launch }), - onEvent: (event) => events.push(event), openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`, - ...deps + ...deps, + onEvent: (event) => { + if (event.type === 'options-reported') { + reports.set(event.sessionId, event) + } else { + events.push(event) + } + deps.onEvent?.(event) + } }) + startReports.set(adapter, reports) + startLinks.set(adapter, links) + return adapter } export async function acquired( @@ -141,6 +201,10 @@ export async function acquired( events: CodexStructuredSessionEvent[] = [] ): Promise { const adapter = adapterFor(codex, launch, events) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) return adapter } diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index 97821f36c92..6392863ba2d 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { describe, expect, it } from 'vitest' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { @@ -125,7 +126,11 @@ async function acquired( events: CodexStructuredSessionEvent[] = [] ): Promise { const adapter = adapterFor(codex, launch, events) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) return adapter } @@ -133,8 +138,16 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { it('keeps sessions isolated and closes each child once', async () => { const codex = fakeCodex() const adapter = adapterFor(codex) - await adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-a' }) - await adapter.acquire({ identity: identityFor('session-2'), fence: 1, spawnToken: 'spawn-b' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 1, + spawnToken: 'spawn-a' + }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-2'), + fence: 1, + spawnToken: 'spawn-b' + }) codex.connections[0].handlers.onServerRequest?.({ id: 21, @@ -198,7 +211,11 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const adapter = await acquired(codex, {}, events) - await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 8, + spawnToken: 'spawn-10' + }) const endedBeforeStaleExit = events.filter((event) => event.type === 'ended').length codex.connections[0].handlers.onExit?.(new Error('the superseded child died')) @@ -265,7 +282,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { publish: () => {} } const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 9ed195bfd24..7ec13c1aa18 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -1,16 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' -import { - CodexAppServerRequestError, - type openCodexAppServerConnection -} from './codex-app-server-connection' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env' -import { - CodexStructuredSessionAdapter, - type CodexStructuredLaunch, - type CodexStructuredSessionEvent -} from './codex-structured-session-adapter' import { + codexStartReport, + codexStartedLink, + acquireReadyCodexForTest, THREAD_ID, USER_MESSAGE, acquired, @@ -19,65 +10,58 @@ import { fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import { describe, expect, it, vi } from 'vitest' +import { + CodexAppServerRequestError, + type openCodexAppServerConnection +} from './codex-app-server-connection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + CodexStructuredSessionAdapter, + type CodexStructuredLaunch, + type CodexStructuredSessionEvent +} from './codex-structured-session-adapter' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support' import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' -import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' describe('CodexStructuredSessionAdapter.acquire', () => { it('keeps a started thread usable when its model listing never answers', async () => { const codex = fakeCodex({ 'model/list': () => new Promise(() => {}) }) const adapter = adapterFor(codex) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', options: { model: 'gpt-saved', effort: 'low', serviceTier: 'priority' } }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-saved', effort: 'low', serviceTier: 'priority' }) expect(codex.connections[0].calls.map((call) => call.method)).toEqual([ + 'initialize', 'thread/start', 'model/list' ]) }) - it('persists saved model and effort as the next turn uses them', async () => { - const codex = fakeCodex() - const adapter = adapterFor(codex) - await adapter.acquire({ - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'gpt-saved', effort: 'low', personality: 'concise' } - }) - - expect( - adapter.readAcquisitionOptions({ - sessionId: 'session-1', - fence: 7, - priorOptions: { model: 'gpt-saved', effort: 'low', personality: 'concise' } - }) - ).toEqual({ model: 'gpt-saved', effort: 'low', personality: 'concise' }) - }) - it('keeps a saved next-turn pick when resuming a thread that reports its previous pick', async () => { const codex = fakeCodex() codex.routes['turn/start'] = () => ({ turn: { id: 'turn-1' } }) const adapter = adapterFor(codex, { resumeThreadId: THREAD_ID }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', options: { model: 'gpt-next', effort: 'low' } }) - const saved = adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 }) + const saved = codexStartReport(adapter).reportedOptions expect(saved).toMatchObject({ model: 'gpt-next', effort: 'low' }) await adapter.dispatch({ sessionId: 'session-1', @@ -92,13 +76,16 @@ describe('CodexStructuredSessionAdapter.acquire', () => { effort: 'low' }) await adapter.closeSession('session-1') - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10', - options: saved + options: { + ...(saved.model ? { model: saved.model } : {}), + ...(saved.effort ? { effort: saved.effort } : {}) + } }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 8 })).toMatchObject({ + expect(codexStartReport(adapter).reportedOptions).toMatchObject({ model: 'gpt-next', effort: 'low' }) @@ -112,9 +99,13 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) const adapter = adapterFor(codex) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-live', effort: 'medium' }) @@ -124,14 +115,13 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].closeCount).toBe(0) }) - it('starts a new thread and reports the process and link the lease will prove', async () => { + it('opens the thread with the chat identity every command must keep', async () => { const codex = fakeCodex() const record = agentSessionRecordFixture({ sessionId: 'session-1', provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/codex/home' } }) - // The launch its registration composes: the shared seal names the child, not this adapter. const adapter = adapterFor(codex, {}, [], { resolveLaunch: createCodexStructuredLaunchResolver({ store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, @@ -141,32 +131,14 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) }) - const acquisition = await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) - expect(codex.connections[0].launch.env).toEqual({ - [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: 'spawn-9', - CODEX_HOME: '/codex/home', - ORCA_AGENT_SESSION_ID: 'session-1', - ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), - ...(process.platform !== 'win32' - ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } - : {}), - ORCA_USER_DATA_PATH: expect.any(String), - ORCA_ENVIRONMENT: '', - ORCA_PAIRING_CODE: '', - ORCA_REMOTE_PAIRING: '', - // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. - PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) - }) - expect(codex.connections[0].launch.cwd).toBe('/work/repo') - expect(codex.connections[0].calls[0]).toEqual({ + expect(codex.connections[0].calls[1]).toEqual({ method: 'thread/start', - // A chat with no mode asks, stated outright. params: { cwd: '/work/repo', approvalPolicy: 'on-request', @@ -186,34 +158,6 @@ describe('CodexStructuredSessionAdapter.acquire', () => { } } }) - expect(acquisition.process).toEqual({ - hostId: 'host-1', - pid: 4321, - processStartTimeMs: 1_700_000_000_000, - spawnToken: 'spawn-9' - }) - expect(acquisition.link).toEqual({ - linkId: `codex-7-${THREAD_ID}`, - handle: codexProviderHandle(THREAD_ID), - origin: 'created', - mintedAtFence: 7, - observedAt: 1_700_000_000_500 - }) - expect(acquisition.acquisitionGeneration).toBe('generation-1') - }) - - // A thread opened on Codex's configured default and then given a turn on the chosen model - // reads to Codex as a model switch, and it injects the chosen model's whole prompt again. - it('opens the thread on the model the session chose, not on the configured default', async () => { - const codex = fakeCodex() - const adapter = adapterFor(codex, { model: 'gpt-chosen' }) - - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) - - expect(codex.connections[0].calls[0]).toEqual({ - method: 'thread/start', - params: { cwd: '/work/repo', model: 'gpt-chosen' } - }) }) it('resumes the thread the durable handle chain names, not the client one', async () => { @@ -223,13 +167,13 @@ describe('CodexStructuredSessionAdapter.acquire', () => { resumePath: '/rollouts/thread-proven.jsonl' }) - const acquisition = await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) - expect(codex.connections[0].calls[0]).toEqual({ + expect(codex.connections[0].calls[1]).toEqual({ method: 'thread/resume', params: expect.objectContaining({ threadId: 'thread-proven', @@ -237,8 +181,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { path: '/rollouts/thread-proven.jsonl' }) }) - expect(acquisition.link?.origin).toBe('resumed') - expect(acquisition.link?.handle).toEqual(codexProviderHandle('thread-proven')) + expect(codexStartedLink(adapter)?.origin).toBe('resumed') + expect(codexStartedLink(adapter)?.handle).toEqual(codexProviderHandle('thread-proven')) }) it('starts a thread in place of a creation Codex never saved, and says which it replaced', async () => { @@ -256,17 +200,17 @@ describe('CodexStructuredSessionAdapter.acquire', () => { supersedeIfUnsaved: true }) - const acquisition = await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) - expect(codex.connections[0].calls.slice(0, 2).map((call) => call.method)).toEqual([ + expect(codex.connections[0].calls.slice(1, 3).map((call) => call.method)).toEqual([ 'thread/resume', 'thread/start' ]) - expect(acquisition.link).toEqual({ + expect(codexStartedLink(adapter)).toEqual({ linkId: `codex-9-${THREAD_ID}`, handle: codexProviderHandle(THREAD_ID), origin: 'created', @@ -277,37 +221,27 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].closeCount).toBe(0) }) - it('refuses a resume that lands on a different thread and reaps the child', async () => { + it('rejects a resume that lands on a different thread; the host closes once', async () => { const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) }) const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' }) - + const acquired = await adapter.acquire({ + identity: identityFor('session-1'), + fence: 9, + spawnToken: 'spawn-9' + }) await expect( - adapter.acquire({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) + adapter.open({ + sessionId: 'session-1', + fence: 9, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) ).rejects.toThrow('resumed thread-other instead of thread-proven') + expect(codex.connections[0].closeCount).toBe(0) + expect(await adapter.closeSession('session-1')).toBe(true) expect(codex.connections[0].closeCount).toBe(1) }) - it('refuses a thread Codex never named', async () => { - const codex = fakeCodex({ 'thread/start': () => ({}) }) - const adapter = adapterFor(codex) - - await expect( - adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-9' }) - ).rejects.toThrow('did not name the thread') - expect(codex.connections[0].closeCount).toBe(1) - }) - - it('closes the previous child before re-acquiring at a new fence', async () => { - const codex = fakeCodex() - const adapter = await acquired(codex) - - await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) - - expect(codex.connections).toHaveLength(2) - expect(codex.connections[0].closeCount).toBe(1) - expect(codex.connections[1].closeCount).toBe(0) - }) - it('keeps the traffic Codex sends before the session is published', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] @@ -357,7 +291,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -649,7 +583,11 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'gpt-5', fence: 7 }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 5f72bb5da38..9d594e57aaf 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,5 +1,5 @@ import * as codexRewind from './codex-structured-rewind' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { @@ -9,6 +9,7 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenInput } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' @@ -19,6 +20,7 @@ import { prepareLiveCodexSessionOptions, readLiveCodexSessionOptions } from './codex-structured-session-options' +import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { CodexAcquisitionRegistry, requireLiveCodexSession, @@ -100,6 +102,14 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.teardown.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason) }) + open = (input: StructuredAgentSessionProviderOpenInput) => { + const attempt = this.acquisitions.get(input.sessionId) + if (!attempt?.open) { + return Promise.reject(new Error(`no spawned codex child for session ${input.sessionId}`)) + } + return attempt.open(input) + } + /** Buffers pre-publication events and drops events from superseded children. */ private deliver( acquisition: CodexAcquisitionAttempt['window'], @@ -177,6 +187,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap // `ended` is set in the same turn as the connection's own exit report. holdsLiveProviderProcess = (sessionId: string, acquisitionGeneration: string): boolean => { const session = this.sessions.get(sessionId) + const starting = this.acquisitions.get(sessionId) + if (starting?.acquisitionGeneration === acquisitionGeneration) { + return !starting.exitProven && starting.window.connection?.pid !== undefined + } return ( session?.acquisitionGeneration === acquisitionGeneration && session.connection.pid !== undefined && @@ -209,19 +223,20 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap turnId ) - async dispatch(input: { - sessionId: string - clientMessageId: string - body: AgentJournalMessageItem - fence: number - requestedAt?: number - beforeDispatch?: () => Promise - }): Promise { + async dispatch( + input: Parameters[0] + ): Promise { const session = this.session(input.sessionId) session.dispatchPending = true try { await input.beforeDispatch?.() - return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) + input.signal?.throwIfAborted() + const turnOptions = await waitForPromiseWithSignal( + resolvedCodexMessageOptions(session, input.executionOptions), + input.signal + ) + input.signal?.throwIfAborted() + return await dispatchCodexTurn(session, { ...input, turnOptions }, this.deps.requestTimeoutMs) } finally { session.dispatchPending = false } @@ -276,7 +291,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.session(input.sessionId), input.change, input.replacesGoal, - this.deps.requestTimeoutMs + this.deps.requestTimeoutMs, + input.signal ) answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => @@ -298,34 +314,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap prepareReadOptions = (input: { sessionId: string; fence: number }) => prepareLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readAcquisitionOptions = (input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }) => { - const session = this.session(input.sessionId) - const options = { - ...Object.fromEntries( - Object.entries(input.priorOptions ?? {}).filter(([key]) => !isCodexTurnOptionKey(key)) - ), - ...Object.fromEntries(session.options) - } - const reported = session.reportedOptions - // The thread's effort belongs to the thread's model, not to a different saved one. - if ( - options.effort === undefined && - reported.effort && - (options.model === undefined || options.model === reported.model) - ) { - options.effort = reported.effort - } - if (options.model === undefined && reported.model) { - options.model = reported.model - } - return Object.keys(options).length > 0 ? options : undefined - } - - closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + closeSession: NonNullable = (sessionId) => + this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) closeAll = (): Promise => this.teardown.closeAll() diff --git a/src/main/codex/codex-structured-session-background-tasks.test.ts b/src/main/codex/codex-structured-session-background-tasks.test.ts index 8dfc3a15914..58e84f5eeb1 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' @@ -99,7 +100,7 @@ async function adapterWithSession( onChildWorkEvidence: (sessionId, evidence) => published.push({ sessionId, evidence: evidence.map((edge) => edge.type) }) }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -157,7 +158,11 @@ describe("codex background tasks reach the host's child records", () => { const published: Published[] = [] const { adapter, codex } = await adapterWithSession(published) const oldExit = codex.handlers().onExit - await adapter.acquire({ identity: identity('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await acquireReadyCodexForTest(adapter, { + identity: identity('session-1'), + fence: 8, + spawnToken: 'spawn-10' + }) codex.handlers().onNotification?.('turn/started', { threadId: CHILD_ID, turn: { id: 'replacement-child-turn' } diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index f9f9f72ccb1..7a541a2f59e 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' // Every way Orca reads or kills processes, never settling: a Stop that reaches for one hangs. @@ -128,7 +129,7 @@ async function acquired( readProcessStartTime: async () => 1_700_000_000_000, ...overrides }) - await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { identity: identity(), fence: 7, spawnToken: 'spawn-9' }) return adapter } diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index e0004a0f737..c0ba16255f4 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' @@ -70,6 +71,7 @@ function adapterFixture() { function claudeAdapterStub(): StructuredAgentSessionAdapter { return { acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never), + open: vi.fn(async () => ({ reportedOptions: { model: '' } })), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -161,8 +163,8 @@ describe('Codex structured session close lifecycle', () => { const { adapter } = adapterFixture() const input = { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' } - const first = await adapter.acquire(input) - const second = await adapter.acquire(input) + const first = await acquireReadyCodexForTest(adapter, input) + const second = await acquireReadyCodexForTest(adapter, input) expect(first.acquisitionGeneration).toBe('generation-1') expect(second.acquisitionGeneration).toBe('generation-2') @@ -170,9 +172,17 @@ describe('Codex structured session close lifecycle', () => { it('distinguishes an observed provider death from a requested close', async () => { const { adapter, connections, events } = adapterFixture() - await adapter.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identity('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) connections[0]?.handlers.onExit?.(new Error('provider exited')) - await adapter.acquire({ identity: identity('session-2'), fence: 9, spawnToken: 'spawn-2' }) + await acquireReadyCodexForTest(adapter, { + identity: identity('session-2'), + fence: 9, + spawnToken: 'spawn-2' + }) await adapter.closeSession('session-2') @@ -192,7 +202,11 @@ describe('Codex structured session close lifecycle', () => { it('force-close preserves unexpected-exit evidence when the adapter reports exit during close', async () => { const { adapter, connections, events } = adapterFixture() - await adapter.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identity('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) const current = connections[0] if (!current) { throw new Error('missing connection') diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index be214a25d59..d5a0a90ec5b 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -91,9 +91,13 @@ export async function closeCodexPublishedSession( ) { return false } + if (session.ended && session.connection.closed) { + sessions.delete(sessionId) + return true + } // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. - session.orcaClose = { + session.orcaClose ??= { requested: options?.requestedClose ?? true, reason: options?.unexpectedReason ?? new Error('codex session closed') } @@ -124,16 +128,33 @@ export async function closeCodexSession( sessions: Map, acquisitions: CodexAcquisitionRegistry, onEvent?: (event: CodexStructuredSessionEvent) => void, - logger?: StructuredAgentSessionLogger + logger?: StructuredAgentSessionLogger, + requestedClose = true ): Promise { const attempt = acquisitions.get(sessionId) - if (!(await cancelCodexAcquisitionAttempt(attempt))) { + const published = sessions.get(sessionId) + if (attempt && published?.connection === attempt.window.connection) { + attempt.cancelled = true + attempt.requestedClose = requestedClose + const closed = await closeCodexPublishedSession(sessions, sessionId, onEvent, { + requestedClose, + ...(logger ? { logger } : {}) + }) + if (closed) { + acquisitions.deleteIfCurrent(sessionId, attempt) + } + return closed + } + if (!(await cancelCodexAcquisitionAttempt(attempt, requestedClose))) { return false } if (attempt) { acquisitions.deleteIfCurrent(sessionId, attempt) } - return closeCodexPublishedSession(sessions, sessionId, onEvent, logger ? { logger } : {}) + return closeCodexPublishedSession(sessions, sessionId, onEvent, { + requestedClose, + ...(logger ? { logger } : {}) + }) } export async function closeAllCodexSessions( diff --git a/src/main/codex/codex-structured-session-exit-blame.test.ts b/src/main/codex/codex-structured-session-exit-blame.test.ts index 591f0e61ae8..7d9c6e57150 100644 --- a/src/main/codex/codex-structured-session-exit-blame.test.ts +++ b/src/main/codex/codex-structured-session-exit-blame.test.ts @@ -1,10 +1,15 @@ +import { + acquireReadyCodexForTest, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' // Only the Codex app-server child's own exit says Codex stopped; a close Orca made — a journal // sink that could not take a frame, a forced close — is Orca's. import { describe, expect, it, vi } from 'vitest' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' function sink(overrides: Partial = {}) { @@ -15,7 +20,12 @@ async function startedCodex(events: StructuredAgentSessionEventSink) { const codex = fakeCodex() const emitted: CodexStructuredSessionEvent[] = [] const adapter = adapterFor(codex, {}, emitted) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 's', events }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 's', + events + }) const ended = async () => { await vi.waitFor(() => expect(emitted.some((event) => event.type === 'ended')).toBe(true)) return emitted.filter((event) => event.type === 'ended') diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 9898029e14d..a9bd52325e7 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -1,8 +1,12 @@ -import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' +import { + acquireReadyCodexForTest, + USER_MESSAGE, + adapterFor, + answerWithOpenedTurn, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' -import type { CodexAppServerConnection } from './codex-app-server-connection' -import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' import { applyCodexStructuredSessionOption, readCodexStructuredSessionOptions, @@ -10,42 +14,10 @@ import { restoredCodexSessionOptions } from './codex-structured-session-options' import { reportedCodexThreadOptions } from './codex-structured-service-tier' -import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' import { startCodexTurn } from './codex-structured-turn-start' -import { - USER_MESSAGE, - adapterFor, - answerWithOpenedTurn, - fakeCodex, - identityFor -} from './codex-structured-session-adapter-fixture' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' - -function optionSession(request: CodexAppServerConnection['request']): CodexSession { - return { - connection: { - pid: 1, - closed: false, - request, - notify: () => {}, - respond: () => {}, - respondWithError: () => {}, - close: async () => true - }, - backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), - ended: false, - fence: 1, - acquisitionGeneration: 'generation-1', - threadId: 'thread-1', - prompts: new CodexAcquisitionWindow().prompts, - options: new Map(), - reportedOptions: { model: 'gpt-live', effort: 'high' }, - dispatchEchoes: createCodexDispatchEchoes(), - turnOpenWaits: createCodexTurnOpenWaits(), - translator: null - } -} +import { optionSession } from './codex-structured-option-session.test-fixture' async function primePicker(session: CodexSession): Promise { session.catalogAccess = { @@ -688,7 +660,11 @@ describe('Codex option picks before the model list arrives', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) await vi.waitFor(() => expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) ) @@ -702,11 +678,6 @@ describe('Codex option picks before the model list arrives', () => { effort: 'low', serviceTier: 'priority' }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ - model: 'gpt-next', - effort: 'low', - serviceTier: 'priority' - }) await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'first', diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index 9e876bbe118..bbac1d4c300 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -26,6 +26,23 @@ export function restoredCodexSessionOptions( return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key))) } +/** Startup reports the thread's defaults together with the saved next-turn intent. */ +export function reportedCodexSessionOptions( + session: CodexSession +): AgentSessionOptionsResult['current'] { + const model = session.options.get('model') ?? session.reportedOptions.model ?? '' + const effort = + session.options.get('effort') ?? + (model === session.reportedOptions.model ? session.reportedOptions.effort : undefined) + // The saved tier, which the next turn sends as-is. + const serviceTier = session.options.get('serviceTier') + return { + model, + ...(effort ? { effort } : {}), + ...(serviceTier ? { serviceTier } : {}) + } +} + export type { CodexSessionOptionCatalog } from './codex-structured-model-catalog' export { readCodexStructuredSessionOptionCatalog } from './codex-structured-model-catalog' diff --git a/src/main/codex/codex-structured-session-prompts.test.ts b/src/main/codex/codex-structured-session-prompts.test.ts index 4a61f101a7d..e9771afba1f 100644 --- a/src/main/codex/codex-structured-session-prompts.test.ts +++ b/src/main/codex/codex-structured-session-prompts.test.ts @@ -1,14 +1,15 @@ -import { describe, expect, it, vi } from 'vitest' -import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { + acquireReadyCodexForTest, THREAD_ID, acquired, adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import { describe, expect, it, vi } from 'vitest' +import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' describe('CodexStructuredSessionAdapter prompts', () => { function askApproval(codex: ReturnType): void { @@ -61,7 +62,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { tryAppendItem: vi.fn(() => ({ accepted: false as const, reason: 'closed' as const })) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -101,7 +102,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { tryAppendItem: vi.fn(() => ({ accepted: false as const, reason: 'backpressure' as const })) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -126,7 +127,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { tryAppendItem: vi.fn(() => ({ accepted: false as const, reason: 'failed' as const })) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-structured-session-shutdown.test.ts b/src/main/codex/codex-structured-session-shutdown.test.ts index 44ed5c93d41..189a268d954 100644 --- a/src/main/codex/codex-structured-session-shutdown.test.ts +++ b/src/main/codex/codex-structured-session-shutdown.test.ts @@ -1,3 +1,4 @@ +import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { @@ -94,7 +95,11 @@ describe('CodexStructuredSessionAdapter shutdown', () => { readProcessStartTime: async () => 1_700_000_000_000 }) - await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identity(), + fence: 7, + spawnToken: 'spawn-1' + }) await expect(adapter.closeAll()).rejects.toThrow( 'codex structured session shutdown could not prove every child stopped' ) diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts new file mode 100644 index 00000000000..b9a0897fbf2 --- /dev/null +++ b/src/main/codex/codex-structured-session-start.ts @@ -0,0 +1,171 @@ +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionProviderOpenInput, + StructuredAgentSessionProviderOpenResult +} from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { initializeCodexAppServerConnection } from './codex-app-server-handshake' +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CodexBackgroundTaskTracker, codexChildWorkSink } from './codex-background-task-tracker' +import type { CodexSubagentExecutions } from './codex-subagent-executions' +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' +import type { CodexJournalTranslator } from './codex-structured-journal-translation' +import { codexProviderHandleLink } from './codex-structured-owner-identity' +import { openCodexThread } from './codex-structured-thread-open' +import { withCodexVisualsThreadConfig } from './codex-structured-visuals' +import { NATIVE_CHAT_SCRATCH_DIR_ENV } from '../native-chat/native-chat-visuals-delivery' +import { + restoredCodexSessionOptions, + reportedCodexSessionOptions +} from './codex-structured-session-options' +import { startBackgroundCodexCatalogRefresh } from './codex-structured-background-catalog' +import { codexAcquireCatalogAccess } from './codex-structured-acquire-catalog' +import { reportedCodexThreadOptions } from './codex-structured-service-tier' +import { + assertCodexConnectionOpen, + codexSessionLifecycle, + type CodexAcquisitionRegistry, + type CodexAcquisitionAttempt, + type CodexSession, + type CodexStructuredLaunch, + type CodexStructuredSessionAdapterDeps +} from './codex-structured-session-state' +import type { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' + +type CodexOpenContext = { + input: StructuredAgentSessionAcquireInput + deps: CodexStructuredSessionAdapterDeps + launch: CodexStructuredLaunch + account: AgentSessionAccountKind | undefined + sessions: Map + acquisitions: CodexAcquisitionRegistry + attempt: CodexAcquisitionAttempt + connection: CodexAppServerConnection + translator: CodexJournalTranslator | null + dispatchEchoes: CodexDispatchEchoes + subagentExecutions: CodexSubagentExecutions + setPrimaryThreadId: (threadId: string) => void + forceCloseUnexpected: CodexStructuredSessionTeardown['forceCloseUnexpected'] + unbindReadingControl?: () => void +} + +/** The host owns the open promise and closes the spawned child if this rejects. */ +export async function openCodexStructuredSession( + start: CodexOpenContext, + openInput: StructuredAgentSessionProviderOpenInput +): Promise { + const { input, deps, launch, sessions, acquisitions, attempt, connection, translator } = start + const sessionId = input.identity.sessionId + const generation = attempt.acquisitionGeneration + if ( + generation === undefined || + openInput.sessionId !== sessionId || + openInput.fence !== input.fence || + openInput.acquisitionGeneration !== generation + ) { + throw new Error('codex open does not match the spawned child') + } + const current = (): void => { + throwIfSignalAborted(openInput.signal) + if (attempt.exitError) { + throw attempt.exitError + } + acquisitions.assertCurrent(sessionId, attempt) + assertCodexConnectionOpen(connection, sessionId) + } + current() + await waitForPromiseWithSignal(initializeCodexAppServerConnection(connection), openInput.signal) + current() + const threadLaunch = await waitForPromiseWithSignal( + withCodexVisualsThreadConfig(connection, launch, { + sessionId, + ...(deps.logger ? { logger: deps.logger } : {}) + }), + openInput.signal + ) + current() + const commandEnv = { + ...launch.commandEnv, + ...(launch.visuals ? { [NATIVE_CHAT_SCRATCH_DIR_ENV]: launch.visuals.folder } : {}) + } + const opened = await waitForPromiseWithSignal( + openCodexThread( + connection, + { ...threadLaunch, commandEnv }, + deps.requestTimeoutMs, + openInput.signal + ), + openInput.signal + ) + current() + start.setPrimaryThreadId(opened.threadId) + const restored = translator?.restoreThread(opened.threadId, opened.thread ?? {}) + if (restored && !restored.accepted) { + throw AgentSessionAcquisitionRefusal.historyTooLarge( + 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' + ) + } + const catalogAccess = codexAcquireCatalogAccess(deps, launch) + const session: CodexSession = { + account: start.account, + connection, + ...codexSessionLifecycle(input.fence, generation), + threadId: opened.threadId, + historyMode: opened.historyMode, + activeTurnIds: new Set(), + abortedTurnIds: new Set(), + prompts: attempt.window.prompts, + options: restoredCodexSessionOptions(input.options), + reportedOptions: reportedCodexThreadOptions(opened), + ...(launch.resumeThreadId === null && !launch.model && opened.model + ? { + nativeDefaults: { + model: opened.model, + ...(opened.effort ? { effort: opened.effort } : {}) + } + } + : {}), + ...(catalogAccess ? { catalogAccess } : {}), + dispatchEchoes: start.dispatchEchoes, + translator, + backgroundTasks: new CodexBackgroundTaskTracker( + opened.threadId, + start.subagentExecutions, + codexChildWorkSink(sessionId, deps, input.events) + ), + forceCloseUnexpected: (reason) => + start.forceCloseUnexpected(sessionId, input.fence, generation, reason), + ...(start.unbindReadingControl ? { unbindReadingControl: start.unbindReadingControl } : {}) + } + if (opened.serviceTier) { + session.nativeDefaults = { ...session.nativeDefaults, serviceTier: opened.serviceTier } + } + current() + sessions.set(sessionId, session) + for (const event of attempt.window.drain()) { + event() + } + current() + acquisitions.deleteIfCurrent(sessionId, attempt) + startBackgroundCodexCatalogRefresh({ + session, + sessionId, + sessions, + timeoutMs: deps.requestTimeoutMs, + logger: deps.logger + }) + return { + link: codexProviderHandleLink({ + threadId: opened.threadId, + ...(opened.supersededThreadId + ? { resumed: false, supersedesThreadId: opened.supersededThreadId } + : { resumed: launch.resumeThreadId !== null }), + fence: input.fence, + linkId: deps.mintLinkId?.(), + observedAt: deps.now?.() ?? Date.now() + }), + reportedOptions: reportedCodexSessionOptions(session) + } +} diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index ae04e846c00..465dd55f6b6 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -21,13 +21,14 @@ import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' -import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { StructuredLaunchRequest } from '../runtime/structured-agent-launch-composition' import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { StructuredAgentSessionProviderOpen } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals-delivery' export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess @@ -87,7 +88,7 @@ export type CodexStructuredSessionEvent = codexItemId: string promptKey: string } - | StructuredAgentSessionEndedEvent + | StructuredAgentSessionLifecycleEvent /** Translator-only compatibility for callers that do not participate in host recovery. */ | { type: 'ended'; sessionId: string; reason: string; observedAt?: number } @@ -157,6 +158,10 @@ export type CodexSession = { serviceTier?: string | null serviceTierKnown?: true } + /** A fresh thread opened without a model override proved these native defaults. */ + nativeDefaults?: { model?: string; effort?: string; personality?: string; serviceTier?: string } + /** Overrides actually attempted on this child, independently of future saved picks. */ + appliedTurnOptions?: Readonly> /** Absent when the adapter runs without a host catalog store (tests). */ catalogAccess?: CodexSessionCatalogAccess /** Sends whose identity is still to be settled by the provider echo. */ @@ -208,8 +213,13 @@ export function requireLiveCodexSession( export type CodexAcquisitionAttempt = { window: CodexAcquisitionWindow + acquisitionGeneration?: string + open?: StructuredAgentSessionProviderOpen + dispose?: () => void cancelled: boolean + requestedClose?: boolean exitProven: boolean + exitError?: Error finished: Promise finish: () => void } @@ -294,17 +304,24 @@ export class CodexAcquisitionRegistry { } export async function cancelCodexAcquisitionAttempt( - attempt: CodexAcquisitionAttempt | undefined + attempt: CodexAcquisitionAttempt | undefined, + requestedClose = true ): Promise { if (!attempt) { return true } - return cancelProcessAcquisition({ + const closed = await cancelProcessAcquisition({ cancel: () => { attempt.cancelled = true + attempt.requestedClose = requestedClose }, connection: () => attempt.window.connection, exitProven: () => attempt.exitProven, finished: attempt.finished }) + if (closed) { + attempt.exitProven = true + attempt.dispose?.() + } + return closed } diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index e7395fb3c84..12316dcbe80 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -29,7 +29,9 @@ export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} close = async (sessionId: string): Promise => { - const connection = this.deps.sessions.get(sessionId)?.connection + const connection = + this.deps.sessions.get(sessionId)?.connection ?? + this.deps.acquisitions.get(sessionId)?.window.connection const closed = this.settled( sessionId, await closeCodexSession( @@ -50,11 +52,13 @@ export class CodexStructuredSessionTeardown { } forceClose = async (sessionId: string): Promise => { - const closed = await closeCodexPublishedSession( - this.deps.sessions, + const closed = await closeCodexSession( sessionId, + this.deps.sessions, + this.deps.acquisitions, this.deps.onEvent, - { requestedClose: false } + this.deps.logger, + false ) return this.settled(sessionId, closed) } diff --git a/src/main/codex/codex-structured-thread-goal.test.ts b/src/main/codex/codex-structured-thread-goal.test.ts index 97f4cb9fb73..6170a94a201 100644 --- a/src/main/codex/codex-structured-thread-goal.test.ts +++ b/src/main/codex/codex-structured-thread-goal.test.ts @@ -63,6 +63,27 @@ describe('codex thread goal requests', () => { ]) }) + it('awaits a revoked clear and never activates the replacement', async () => { + const clear = Promise.withResolvers() + const authority = new AbortController() + const request = vi.fn(() => clear.promise) + const result = changeCodexThreadGoal( + session(request), + { kind: 'set', objective: 'Ship it' }, + true, + undefined, + authority.signal + ) + const settled = vi.fn() + void result.then(settled, settled) + authority.abort(new Error('stopped')) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + clear.resolve({ goal: null }) + await expect(result).rejects.toThrow('stopped') + expect(request).toHaveBeenCalledOnce() + }) + it('stops at a refused clear, so a refused replacement leaves the old goal alone', async () => { const request = vi.fn(async (method: unknown) => { if (method === 'thread/goal/clear') { diff --git a/src/main/codex/codex-structured-thread-goal.ts b/src/main/codex/codex-structured-thread-goal.ts index c521221390e..b665a6747e8 100644 --- a/src/main/codex/codex-structured-thread-goal.ts +++ b/src/main/codex/codex-structured-thread-goal.ts @@ -1,3 +1,4 @@ +import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import type { AgentSessionThreadGoalChange } from '../../shared/agent-session-thread-goal-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { CodexSession } from './codex-structured-session-state' @@ -36,10 +37,12 @@ export async function changeCodexThreadGoal( session: Pick, change: AgentSessionThreadGoalChange, replacesGoal: boolean, - timeoutMs: number | undefined + timeoutMs: number | undefined, + signal?: AbortSignal ): Promise<{ ok: true } | { ok: false; rejected: string }> { try { for (const request of codexThreadGoalRequests(session.threadId, change, replacesGoal)) { + throwIfSignalAborted(signal) await session.connection.request(request.method, request.params, { timeoutMs }) } return { ok: true } diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 653955b4d8c..2d248909423 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -5,6 +5,7 @@ // recording it would make the durable handle chain lie about what this session // actually proved. +import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import { isCodexAppServerRequestError, type CodexAppServerConnection @@ -74,7 +75,8 @@ export async function openCodexThread( /** Env every command the agent runs must see, whatever the user's shell environment policy. */ commandEnv?: Record }, - timeoutMs: number | undefined + timeoutMs: number | undefined, + signal?: AbortSignal ): Promise { const resumeThreadId = launch.resumeThreadId const config = { ...launch.threadConfig, ...shellEnvironmentSetConfig(launch.commandEnv) } @@ -83,12 +85,14 @@ export async function openCodexThread( ...launch.permissionPolicy, ...(Object.keys(config).length > 0 ? { config } : {}) } - const startThread = (): Promise => - connection.request( + const startThread = (): Promise => { + throwIfSignalAborted(signal) + return connection.request( 'thread/start', { ...threadSettings, ...(launch.model ? { model: launch.model } : {}) }, { timeoutMs } ) + } let supersededThreadId: string | undefined let opened: unknown if (!resumeThreadId) { @@ -101,12 +105,14 @@ export async function openCodexThread( ...(launch.resumePath ? { path: launch.resumePath } : {}) } try { + throwIfSignalAborted(signal) opened = await connection.request( 'thread/resume', { ...resumeParams, excludeTurns: true }, { timeoutMs } ) } catch (error) { + throwIfSignalAborted(signal) if (!launch.supersedeIfUnsaved || !isCodexNoRolloutError(error, resumeThreadId)) { throw error } diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 9dd071656f3..6e7a575f2fa 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -2,6 +2,7 @@ // to: it ended, the thread stopped running, or the child is gone. Held in memory only. import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import { codexThreadStoppedRunning, @@ -142,7 +143,8 @@ export async function interruptOpeningCodexTurn( * by then. */ export async function codexRunningOrOpeningTurn( - session: CodexOpeningTurnSession + session: CodexOpeningTurnSession, + signal?: AbortSignal ): Promise { const running = [...(session.activeTurnIds ?? [])].at(-1) if (running) { @@ -155,7 +157,10 @@ export async function codexRunningOrOpeningTurn( if (!answered) { return null } - await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) + await waitForPromiseWithSignal( + session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS), + signal + ) if (session.activeTurnIds?.has(answered)) { return answered } diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 8f67013a76b..6b52918d8bb 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -42,12 +42,20 @@ export function isCodexTurnOptionKey(key: string): boolean { return CODEX_TURN_OPTION_KEYS.has(key) } +function checkDispatchAuthority(host: CodexTurnHost, id: string, signal?: AbortSignal): void { + if (signal?.aborted) { + host.dispatchEchoes.disarm(id) + signal.throwIfAborted() + } +} + /** The session state one turn needs. */ export type CodexTurnHost = { account?: AgentSessionAccountKind connection: Pick threadId: string options: Map + appliedTurnOptions?: Readonly> reportedOptions?: { model?: string } dispatchEchoes: CodexDispatchEchoes activeTurnIds?: ReadonlySet @@ -82,10 +90,12 @@ async function steerCodexTurn( clientMessageId: string body: AgentJournalMessageItem timeoutMs?: number + signal?: AbortSignal onReply?: () => void } ): Promise<{ turnId: string; via: 'steer' } | null> { try { + checkDispatchAuthority(host, input.clientMessageId, input.signal) const answer = await host.connection.request( 'turn/steer', { @@ -117,6 +127,8 @@ export async function startCodexTurn( body: AgentJournalMessageItem requestedAt?: number timeoutMs?: number + turnOptions?: Readonly> + signal?: AbortSignal /** Stamped as each reply is read (`CodexDispatchEchoes.tick`); the last is this send's. */ onReply?: () => void } @@ -130,7 +142,13 @@ export async function startCodexTurn( // the send opens its own turn. const steerable = (turnId: string | null | undefined): turnId is string => typeof turnId === 'string' && turnId !== '' && !host.abortedTurnIds?.has(turnId) - const runningTurnId = await codexRunningOrOpeningTurn(host) + let runningTurnId: string | null + try { + runningTurnId = await codexRunningOrOpeningTurn(host, input.signal) + } catch (error) { + host.dispatchEchoes.disarm(input.clientMessageId) + throw error + } let steered = steerable(runningTurnId) ? await steerCodexTurn(host, runningTurnId, input) : null // Refused because a turn Orca heard of meanwhile is running: steer that one, once. const runningSince = steered ? undefined : [...(host.activeTurnIds ?? [])].at(-1) @@ -140,17 +158,42 @@ export async function startCodexTurn( if (steered) { return steered } - const answer = await host.connection.request( - 'turn/start', - { - threadId: host.threadId, - clientUserMessageId: input.clientMessageId, - input: turnInputFor(input.body), - // Codex omits a tier the model does not list. - ...Object.fromEntries(host.options) - }, - { timeoutMs: input.timeoutMs, onReply: input.onReply } - ) + checkDispatchAuthority(host, input.clientMessageId, input.signal) + const previousApplied = host.appliedTurnOptions + const previousOptions = input.turnOptions ? new Map(host.options) : null + if (input.turnOptions) { + host.options.clear() + for (const [key, value] of Object.entries(input.turnOptions)) { + host.options.set(key, value) + } + } + host.appliedTurnOptions = input.turnOptions ?? Object.fromEntries(host.options) + let answer: unknown + try { + answer = await host.connection.request( + 'turn/start', + { + threadId: host.threadId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(input.body), + // Codex omits a tier the model does not list. + ...(input.turnOptions ?? Object.fromEntries(host.options)) + }, + { timeoutMs: input.timeoutMs, onReply: input.onReply } + ) + } catch (error) { + if ( + previousOptions && + (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) + ) { + host.appliedTurnOptions = previousApplied + host.options.clear() + for (const [key, value] of previousOptions) { + host.options.set(key, value) + } + } + throw error + } return { turnId: readCodexTurnId(answer), via: 'start' } } @@ -167,6 +210,8 @@ export async function dispatchCodexTurn( clientMessageId: string body: AgentJournalMessageItem requestedAt?: number + turnOptions?: Readonly> + signal?: AbortSignal }, timeoutMs: number | undefined ): Promise { diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index 596baab2986..427683d774d 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -38,7 +38,9 @@ async function rig(routes: Record) { const methods = () => connection.calls .map(({ method }) => method) - .filter((method) => method !== 'model/list' && method !== 'config/read') + .filter( + (method) => method !== 'initialize' && method !== 'model/list' && method !== 'config/read' + ) const endTurn = (turnId: string, status: 'completed' | 'interrupted') => connection.handlers.onNotification?.('turn/completed', { threadId: CODEX_TEST_THREAD_ID, @@ -216,7 +218,7 @@ describe('a Codex send made after Codex answered an earlier one, before it opene expect(await settledWithin(sending)).toBe('held') const methods = () => rig.codex.connections[0]!.calls.map(({ method }) => method).filter( - (method) => method !== 'model/list' && method !== 'config/read' + (method) => method !== 'initialize' && method !== 'model/list' && method !== 'config/read' ) expect(methods()).toEqual(['thread/start', 'turn/start']) return { ...rig, sending, methods } diff --git a/src/main/codex/codex-structured-visuals.test.ts b/src/main/codex/codex-structured-visuals.test.ts index b4be2830211..004d2e6d8ff 100644 --- a/src/main/codex/codex-structured-visuals.test.ts +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -7,6 +7,7 @@ import { CodexAppServerRequestError } from './codex-app-server-request-error' import { CodexAppServerTimeoutError } from './codex-app-server-session' import { adapterFor, + acquireReadyCodexForTest, fakeCodex, identityFor, THREAD_ID @@ -192,7 +193,11 @@ async function acquired( prepareVisuals: async () => launch.visuals ?? null }) }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) } describe('a Codex chat acquisition with visuals', () => { @@ -203,6 +208,7 @@ describe('a Codex chat acquisition with visuals', () => { const connection = codex.connections[0]! const methods = connection.calls.map((call) => call.method) const open = resumeThreadId ? 'thread/resume' : 'thread/start' + expect(methods[0]).toBe('initialize') expect(methods.indexOf('skills/extraRoots/set')).toBeLessThan(methods.indexOf(open)) expect(methods.indexOf('config/read')).toBeLessThan(methods.indexOf(open)) expect(connection.calls.find((call) => call.method === open)?.params).toMatchObject({ @@ -221,10 +227,11 @@ describe('a Codex chat acquisition with visuals', () => { await acquired(codex, { env: { [NATIVE_CHAT_SCRATCH_DIR_ENV]: '/other' } }) const connection = codex.connections[0]! const methods = connection.calls.map((call) => call.method) - expect(methods[0]).toBe('thread/start') + // The adapter sends `initialize` itself now; nothing comes between it and the thread. + expect(methods.slice(0, 2)).toEqual(['initialize', 'thread/start']) expect(methods).not.toContain('skills/extraRoots/set') // Only the chat's identity overrides: no visuals root, and no inherited folder handed on. - const config = connection.calls[0]?.params?.config + const config = connection.calls[1]?.params?.config const scratchDirOverride = `shell_environment_policy.set.${NATIVE_CHAT_SCRATCH_DIR_ENV}` expect(config).not.toHaveProperty(['sandbox_workspace_write.writable_roots']) expect(config).not.toHaveProperty([scratchDirOverride]) diff --git a/src/main/codex/codex-subagent-interrupts.test.ts b/src/main/codex/codex-subagent-interrupts.test.ts index ea04708fe45..522b14d1988 100644 --- a/src/main/codex/codex-subagent-interrupts.test.ts +++ b/src/main/codex/codex-subagent-interrupts.test.ts @@ -4,6 +4,7 @@ import { codexPrimaryCommandTaskId } from '../../shared/structured-session-foreg import { CodexAppServerRequestError } from './codex-app-server-connection' import { CodexAppServerTimeoutError } from './codex-app-server-session' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor, @@ -34,7 +35,7 @@ async function runningHelper(routes: Record) { const live = () => codex.connections.at(-1)! const notify = (method: string, params: unknown): void => live().handlers.onNotification?.(method, params) - await adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) notify('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1', status: 'inProgress' } }) notify('item/started', { threadId: THREAD_ID, diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts index 551beb40dec..d9c5667d961 100644 --- a/src/main/jsonl-rpc/agent-connection.ts +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -1,5 +1,6 @@ import type { PipedProcessSpawner } from '@orca/process-host/process-spec' import { spawnProcess } from '@orca/process-host' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { spawnManagedProviderProcess, type ManagedProviderProcess, @@ -18,8 +19,6 @@ import { resolveJsonlRpcPeerOptions, type JsonlRpcPeerOptions } from './peer-lim export type JsonlRpcAgentConnectionOptions = JsonlRpcPeerHandlers & { peer?: JsonlRpcPeerOptions onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void - /** Any stdout or stderr chunk from the child. */ - onOutput?: () => void } /** The owning runtime keeps process exit evidence separate from transport closure. */ @@ -38,8 +37,7 @@ export class JsonlRpcAgentConnection { const peerOptions = resolveJsonlRpcPeerOptions(options.peer) this.managed = spawnManagedProviderProcess(launch, { spawnImpl, - site: 'jsonl-rpc-agent-teardown', - ...(options.onOutput ? { onOutput: options.onOutput } : {}) + site: 'jsonl-rpc-agent-teardown' }) const managed = this.managed this.peer = new JsonlRpcPeer( @@ -81,10 +79,14 @@ export class JsonlRpcAgentConnection { managed.child.stderr.on('error', this.onError) managed.onExit((exit) => { clearTimeout(this.streamExitTimer) - const error = new Error( - exit.processless - ? 'Agent process could not start' - : `Agent process exited (code ${exit.code ?? 'none'}, signal ${exit.signal ?? 'none'})` + // The agent's last stderr lines are what a person can act on when it dies. + const error = withProviderDiagnostic( + new Error( + exit.processless + ? 'Agent process could not start' + : `Agent process exited (code ${exit.code ?? 'none'}, signal ${exit.signal ?? 'none'})` + ), + providerDiagnostic(managed.stderrTail(), 'person') ) this.peer.finishOnInputEnd(error) const stdout = managed.child.stdout diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts index 79b49d284a0..eb4be98e707 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -66,7 +66,7 @@ export type AgentModelCatalogConfiguredDefault = { * session can't say (it listed or runs no model); null when it runs a model it didn't list. */ export function unpickedSessionConfiguredChoice(session: { resolvesConfig: boolean - picked: ReadonlySet + picked?: ReadonlySet models: readonly { id: string }[] current: { model?: string; effort?: string } }): AgentModelCatalogConfiguredChoice | null | undefined { @@ -74,7 +74,7 @@ export function unpickedSessionConfiguredChoice(session: { // An empty listing (a signed-out agent) is doubt, not a catalog. if ( !session.resolvesConfig || - session.picked.has('model') || + session.picked?.has('model') || !current.model || models.length === 0 ) { @@ -86,7 +86,7 @@ export function unpickedSessionConfiguredChoice(session: { return { modelId: current.model, // An effort this session picked is its own and says nothing of the config's. - ...(session.picked.has('effort') ? {} : { effort: current.effort ?? null }) + ...(session.picked?.has('effort') ? {} : { effort: current.effort ?? null }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 2936fe96544..42d8acce30e 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -6,6 +6,7 @@ import type Database from '../../sqlite/sync-database' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS @@ -111,7 +112,7 @@ export class JournalQueuedMessages { hostInstance: string requireAttachments?: true holdReason?: QueuedMessageHoldReason - options?: Readonly> + options?: AgentSessionExecutionOptions }, receipt?: JournalOperationReceipt ): Promise { diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 6e3a091c0d3..2ffc6c7e720 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -9,6 +9,7 @@ import type { AgentSessionJournalIdentity, AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' @@ -99,7 +100,7 @@ export function journalSubmissionRowBuilder( handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' - options?: Readonly> + options?: AgentSessionExecutionOptions }, /** Present when the append hands off a queued draft: the row names that draft, stamped here * from the consume itself so no hand-off path can leave the link off. */ @@ -303,7 +304,7 @@ export function buildJournalSubmissionRow(input: { queuedMessageId?: string origin?: 'client' | 'host' source?: AgentJournalSubmission['source'] - options?: Readonly> + options?: AgentSessionExecutionOptions }): JournalSubmissionRow { return { kind: 'submission', diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 88cde342d37..3f63c58d336 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -22,6 +22,7 @@ // sent-message kind. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalDispatchState, @@ -177,9 +178,9 @@ export type JournalSubmissionRow = JournalRowBase & { /** Who it is from: its `AgentSessionMessageSource`'s kind only (`AgentJournalSubmission`). * Absent on rows from before it was recorded. Older readers keep the key and ignore it. */ source?: { kind: string } - /** The model and effort the person's composer sent it with, applied when it is handed over. - * Older readers keep the key and ignore it. */ - options?: Readonly> + /** The choices it was accepted with, applied when the agent is handed it. A row from an older + * build may carry other keys, which a read ignores. Older readers keep the key and ignore it. */ + options?: AgentSessionExecutionOptions } export type JournalSubmissionOrigin = 'client' | 'host' diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index c9f2feae47b..d3da7bb1c3c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -12,6 +12,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' @@ -132,8 +133,8 @@ export type JournalSubmissionInput = { origin?: 'client' | 'host' /** Who it is from (`JournalSubmissionRow.source`); the row keeps the kind only. */ source?: Pick - /** The model and effort it runs on (`JournalSubmissionRow.options`). */ - options?: Readonly> + /** The choices it runs on (`JournalSubmissionRow.options`). */ + options?: AgentSessionExecutionOptions } /** A submission append that converts a queued draft, in one transaction. */ diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 01d9e47ac7b..2dbf0e72101 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -6,17 +6,18 @@ import { type AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { journalRenderItem } from './journal-render-item' import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' import { journalDispatchRowApplies } from './journal-dispatch-settlement' -import { isAgentSessionOptions } from '../../../shared/agent-session-record' export function applyJournalSubmission( state: JournalReducerState, row: Extract ): void { + const options = readAgentSessionExecutionOptions(row.options) state.submissions.set(row.clientMessageId, { clientMessageId: row.clientMessageId, fence: row.fence, @@ -36,8 +37,8 @@ export function applyJournalSubmission( // Kept as written, a newer build's kind too; an undecodable one as an empty kind, so neither // reads as a row without one. ...(row.source !== undefined ? { source: { kind: storedSourceKind(row.source) } } : {}), - // A stored value this build cannot read only loses the model it carried, never the row. - ...(isAgentSessionOptions(row.options) ? { options: row.options } : {}) + // A stored value this build cannot read only loses the choices it carried, never the row. + ...(options ? { options } : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. diff --git a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts index 16094cfd45e..e291d69e647 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -5,7 +5,7 @@ import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-sessio import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { readStoredRejectionFact } from './journal-dispatch-reducer' import type { QueuedMessageRow } from './queued-message-table' -import { isAgentSessionOptions } from '../../../shared/agent-session-record' +import { readAgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' /** A `queued_messages` row as this file's SELECTs return it; null when it cannot be read back. */ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | null { @@ -72,14 +72,14 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul } } -/** A bad value only loses the model the send carried, never the card. */ -function storedOptions(json: string | null): { options?: Record } { +/** A bad value only loses the choices the send carried, never the card. */ +function storedOptions(json: string | null): Pick { if (json === null) { return {} } try { - const options: unknown = JSON.parse(json) - return isAgentSessionOptions(options) ? { options } : {} + const options = readAgentSessionExecutionOptions(JSON.parse(json)) + return options ? { options } : {} } catch { return {} } diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 827dd6111f6..1a89cb6f5cc 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -17,6 +17,7 @@ import type { import type { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../shared/agent-session-queued-message-wire' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredQueuedMessageRow } from './queued-message-stored-row' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' @@ -61,8 +62,8 @@ export type QueuedMessageRow = { /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ queuedAt: AgentJournalCursor | null - /** The model and effort its send carried, applied to the turn it starts when it goes out. */ - options?: Readonly> + /** The choices its send carried, applied to the turn it starts when it goes out. */ + options?: AgentSessionExecutionOptions } const COLUMNS = @@ -81,7 +82,7 @@ export function insertQueuedMessage( /** Absent: after every other card. */ position?: number holdReason?: QueuedMessageHoldReason - options?: Readonly> + options?: AgentSessionExecutionOptions } ): QueuedMessageRow { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the statement selects exactly one aliased numeric column; better-sqlite3 types rows as unknown. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index c64c31b988f..53d3051b4cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -70,7 +70,6 @@ let idleMs: number let clock: number const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, link: { @@ -102,7 +101,12 @@ async function startHost(): Promise { cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), - ...adapterExtras + ...adapterExtras, + open: + adapterExtras.open ?? + (async () => ({ + reportedOptions: { model: '' } + })) }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', @@ -243,10 +247,6 @@ function framedStates(events: AgentSessionSubscribeEvent[], id: string): string[ ) } -function deferred() { - return Promise.withResolvers() -} - /** Rows written by an earlier host process that ended before handing them over. */ async function writeAsEarlierProcess( write: (journal: AgentSessionJournal, fence: number) => Promise @@ -282,13 +282,13 @@ function earlierSubmission(id: string, text: string, handoverRecorded?: true) { describe('a send is answered at acceptance', () => { it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() + const starting = Promise.withResolvers() acquire.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) }) - const answering = deferred() + const answering = Promise.withResolvers() const answer = dispatch.getMockImplementation()! dispatch.mockImplementationOnce(async (input) => { await answering.promise @@ -310,7 +310,7 @@ describe('a send is answered at acceptance', () => { it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() + const starting = Promise.withResolvers() acquire.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) @@ -606,8 +606,8 @@ describe('what an earlier host process left behind', () => { }) }) -describe('a child that exits before its message is handed over', () => { - it('rejects the message with the exit reason instead of starting another child (W24)', async () => { +describe('a child that exits before open completes', () => { + it('rejects the message with the start failure instead of starting another child (W24)', async () => { await host.close(SESSION, 'evict') await startHost() // The child exits before the host can finish its protocol open. @@ -657,8 +657,7 @@ describe('a start that fails while messages wait on it', () => { failure: agentSessionFailureFact('providerStartFailed', { detail: { text: 'codex: config.toml is invalid', audience: 'person' } }), - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) await host.flushStreamedEvents(SESSION) @@ -715,25 +714,6 @@ describe('Stop withdraws what is queued', () => { expect(acquire).toHaveBeenCalledTimes(1) }) - it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { - await host.close(SESSION, 'evict') - const starting = deferred() - acquire.mockImplementationOnce(async (input) => { - await starting.promise - return spawnChild(input) - }) - const id = await accept('hello') - await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) - const stopped = stop() - - starting.resolve() - expect(await stopped).toMatchObject({ ok: true }) - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - expect(await submission(id)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) - expect((await submission(id))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - }) - it('stops a child still proving its start, withdrawing what was held for it, and the next send starts another (W17c)', async () => { const closeSession = vi.fn(async () => true) adapterExtras = { closeSession } @@ -760,7 +740,7 @@ describe('Stop withdraws what is queued', () => { describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { - const started = deferred() + const started = Promise.withResolvers() await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => { @@ -788,7 +768,7 @@ describe('an eviction between acceptance and handover', () => { const handed = await accept('handed over') await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) // Held, the second send is accepted and the close runs next, ahead of its handover. - const second = deferred() + const second = Promise.withResolvers() void host['tasks'].serialize(SESSION, () => second.promise) const accepting = accept('still queued') const closing = host.close(SESSION, 'evict') @@ -806,7 +786,7 @@ describe('an eviction between acceptance and handover', () => { it('does not stop an idle child while a message is still queued for it (W24)', async () => { idleMs = 1_000 - const started = deferred() + const started = Promise.withResolvers() await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => { @@ -840,7 +820,6 @@ describe('a close that stops the child and then a wind-down step fails', () => { it.each([{ end: 'evict', starting: true, kind: 'chatClosed', verdict: null }] as const)( 'rejects what is queued as $kind after a $end (during startup: $starting)', async ({ end, starting, kind, verdict }) => { - const started = deferred() adapterExtras = { // Once: the host's own teardown acknowledges again. acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { @@ -849,17 +828,12 @@ describe('a close that stops the child and then a wind-down step fails', () => { } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => { - await started.promise - return (starting ? spawnStartingChild : spawnChild)(input) - }) + acquire.mockImplementationOnce(starting ? spawnStartingChild : spawnChild) const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(() => expect(childPhase()).toBe('starting')) - // Asked for during the start step, the close runs ahead of the handover step. - const ending = END_CHILD[end]() - started.resolve() - await expect(ending).resolves.toBeUndefined() + // Asked for while the child is still starting, the close runs ahead of the handover step. + await expect(END_CHILD[end]()).resolves.toBeUndefined() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) const rejected = (await submission(id))! diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts index c5c0d3a06f8..59bcc5b200e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -1,23 +1,34 @@ // Concurrent host waits keep their own cancellation handles until they settle. export class StructuredAgentSessionAcquireAborts { - private readonly inFlight = new Map>() + private readonly inFlight = new Map< + string, + Set<{ controller: AbortController; handover: boolean; goal: boolean }> + >() /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ private quitReason: Error | null = null - /** For the attach about to run; `end` once it settles. */ - begin(sessionId: string): { signal: AbortSignal; end: () => void } { + /** For the provider wait about to run; `end` once it settles. */ + begin( + sessionId: string, + handover = false, + goal = false + ): { signal: AbortSignal; abort: () => void; end: () => void } { const controller = new AbortController() if (this.quitReason) { controller.abort(this.quitReason) } - const waits = this.inFlight.get(sessionId) ?? new Set() - waits.add(controller) + const waits = + this.inFlight.get(sessionId) ?? + new Set<{ controller: AbortController; handover: boolean; goal: boolean }>() + const active = { controller, handover, goal } + waits.add(active) this.inFlight.set(sessionId, waits) return { signal: controller.signal, + abort: () => controller.abort(new Error('stopped while starting')), end: () => { - waits.delete(controller) + waits.delete(active) if (waits.size === 0 && this.inFlight.get(sessionId) === waits) { this.inFlight.delete(sessionId) } @@ -25,18 +36,31 @@ export class StructuredAgentSessionAcquireAborts { } } - /** A no-op when the session has nothing in flight. */ - abort(sessionId: string, reason: string | Error): void { - for (const controller of this.inFlight.get(sessionId) ?? []) { - controller.abort(typeof reason === 'string' ? new Error(reason) : reason) - } + captureOperations(sessionId: string): (() => void)[] { + return [...(this.inFlight.get(sessionId) ?? [])] + .filter(({ controller, goal }) => goal && !controller.signal.aborted) + .map( + ({ controller }) => + () => + controller.abort(new Error('stopped during goal change')) + ) } - /** Quit: every start under way stops, and so does any the attach drain still runs. */ + /** A no-op when the session has nothing in flight. */ + abort(sessionId: string, reason: string | Error): boolean { + let reached = false + for (const { controller, handover, goal } of this.inFlight.get(sessionId) ?? []) { + reached ||= (handover || goal) && !controller.signal.aborted + controller.abort(typeof reason === 'string' ? new Error(reason) : reason) + } + return reached + } + + /** Quit: every provider wait under way stops, including those queued in the attach drain. */ abortAll(reason: string): void { this.quitReason ??= new Error(reason) for (const waits of this.inFlight.values()) { - for (const controller of waits) { + for (const { controller } of waits) { controller.abort(this.quitReason) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts index f04c3f49b13..2a29cb39a71 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts @@ -15,6 +15,7 @@ it("creates a chat while another saved chat's owner check fails", async () => { await attach() const prior = hostTestState() prior.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: HOST_TEST_NOW, spawnToken }, link: { linkId: 'new-link', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts deleted file mode 100644 index ffb50f0ef99..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ /dev/null @@ -1,708 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { digestPayload } from '../agent-session-journal/journal-payload-bounds' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach } from './structured-agent-session-attach-flow' -import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'legacy-session' -const CREATE_OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -const RESUME_OPERATION = `${NOW}-${'2'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function attachParams( - operationId: string, - expectedRuntimeFence: number | null, - options?: Readonly> -): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: operationId, - expectedRuntimeFence, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - ...(options ? { options } : {}), - providerHandle: { kind: 'codex', threadId: 'legacy-thread' } - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -function adapter(input: { - origin: 'created' | 'resumed' - options?: AgentSessionOptionsResult - restoreFailures?: readonly string[] -}): StructuredAgentSessionAdapter { - return { - acquire: vi - .fn() - .mockImplementation(async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `${input.origin}-link`, - handle: codexProviderHandle('legacy-thread'), - origin: input.origin, - mintedAtFence: fence, - observedAt: NOW - } - })), - ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), - ...(input.restoreFailures - ? { readOptionRestoreFailures: vi.fn(() => input.restoreFailures!) } - : {}), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -function expectSettledAttachLease(record: AgentSessionRecord | null): void { - expect(record).not.toBeNull() - const lease = record!.lease - const durableState = lease.handoffStage ?? lease.claimStatus - expect(['live', 'released', 'recovering']).toContain(durableState) - expect(lease.handoffStage).not.toBe('new-owner-proving') -} - -describe('structured session acquisition options', () => { - it('samples provider history before acquiring a replacement child', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) - const initialStore = await openTestAgentSessionRecordStore(root) - let childAcquired = false - const historyWindow = (): ProviderHistoryWindow => ({ - items: [], - boundaryConsistent: true, - turnInFlight: childAcquired - }) - const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { - const sessionAdapter = adapter({ origin }) - const acquire = vi.mocked(sessionAdapter.acquire) - acquire.mockImplementation(async (input) => { - childAcquired = true - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken: input.spawnToken - }, - link: { - linkId: `${origin}-link`, - handle: codexProviderHandle('legacy-thread'), - origin, - mintedAtFence: input.fence, - observedAt: NOW - } - } - }) - sessionAdapter.providerHistoryWindow = vi.fn(async () => historyWindow()) - return sessionAdapter - } - - let firstJournal: AgentSessionJournal | undefined - const first = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: initialStore, - adapter: withHistory('created'), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: (attached) => { - firstJournal = attached.journal - } - }) - expect(first).toMatchObject({ ok: true }) - await firstJournal!.appendSubmission({ - clientMessageId: 'crashed-send', - payloadFingerprint: digestPayload('deploy the thing'), - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'deploy the thing' }] - } satisfies AgentJournalMessageItem, - fence: 1 - }) - await firstJournal!.close() - const store = await openTestAgentSessionRecordStore(root) - await store.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - childAcquired = false - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - const second = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: withHistory('resumed'), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(RESUME_OPERATION, releasedFence), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(second).toMatchObject({ ok: true, value: { unconfirmedClientMessageIds: [] } }) - expect(second).toMatchObject({ - value: { - page: { submissions: [{ clientMessageId: 'crashed-send', dispatchState: 'rejected' }] } - } - }) - }) - - it('persists create defaults before the first provider acquisition', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) - const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } - const recordPhase = vi.fn() - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - recordPhase, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.acquire).toHaveBeenCalledWith( - expect.objectContaining({ options, recordPhase }) - ) - expect(store.getRecord(SESSION)?.options).toEqual(options) - }) - - it('replays a create retried after the host re-resolved different options', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-create-retry-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) - const attempt = async (options: Readonly>, spawnToken: string) => - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken, - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - onAttached: () => {} - }) - - const created = await attempt({ model: 'gpt-5.6-sol', effort: 'medium' }, 'spawn-a') - // Why: the user may reselect a model between an unknown-outcome create and the - // retry that reuses its operation id; the retry must replay, not conflict. - const retried = await attempt({ model: 'gpt-5.5', effort: 'high' }, 'spawn-b') - - expect(created).toMatchObject({ ok: true }) - expect(retried).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' }) - }) - - it('persists provider options before proving a resumed legacy record', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) - const store = await openTestAgentSessionRecordStore(root) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: () => {} - }) - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toBeUndefined() - await store.replaceSessionOptions({ - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - options: { approvalPolicy: 'on-request', personality: 'concise', fastMode: 'true' }, - now: NOW - }) - - const resumedStore = await openTestAgentSessionRecordStore(root) - await resumedStore.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const resumed = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: resumedStore, - adapter: adapter({ - origin: 'resumed', - options: { - current: { model: 'gpt-5.6-terra', effort: 'medium', fastMode: false }, - models: [] - } - }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(RESUME_OPERATION, releasedFence), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(resumed).toMatchObject({ ok: true }) - const reopened = await openTestAgentSessionRecordStore(root) - expect(reopened.getRecord(SESSION)?.options).toEqual({ - approvalPolicy: 'on-request', - personality: 'concise', - model: 'gpt-5.6-terra', - effort: 'medium', - fastMode: 'false' - }) - }) - - it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ - origin: 'created', - options: { current: { model: 'gpt-standard' }, models: [] }, - restoreFailures: ['fastMode'] - }) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { - model: 'gpt-standard', - fastMode: 'true' - }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard' }) - }) - - it('proves an opened Codex owner from effective options without reading its catalog', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-known-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => ({ model: 'gpt-reported', effort: 'high' })) - } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { model: 'gpt-saved', effort: 'medium' }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-reported', effort: 'high' }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('retains saved options when an opened Codex thread reports no effective model', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-saved-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => undefined) - } - const options = { model: 'gpt-saved', effort: 'medium' } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual(options) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - }) - - it('releases an acquisition when provider options cannot be read', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) - const store = await openTestAgentSessionRecordStore(root) - const releaseAcquisition = vi.fn(async () => true) - const failingAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - releaseAcquisition - } - - await expect( - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: failingAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: () => {} - }) - ).resolves.toEqual({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: "Codex couldn't restart. Send your message to try again." - } - }) - expect(releaseAcquisition).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() - }) - - describe.each([ - ['adapter acquire', 'acquire'], - ['options read', 'options'], - ['identity commit', 'identity'], - ['owner proof', 'proof'], - ['journal attach', 'journal'] - ] as const)('%s failure', (_label, failurePoint) => { - it.each([ - ['proven cleanup', true], - ['unproven cleanup', false], - ['cleanup error', 'throws'] - ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { - const exitProven = cleanup === true - root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) - const store = await openTestAgentSessionRecordStore(root) - const base = adapter({ - origin: 'created', - options: { current: { model: 'gpt-5.6-terra' }, models: [] } - }) - const injected = new Error(`${failurePoint} failed`) - const acquire = vi.mocked(base.acquire) - const readOptions = vi.mocked(base.readOptions!) - if (failurePoint === 'acquire') { - acquire.mockRejectedValueOnce(injected) - } else if (failurePoint === 'options') { - readOptions.mockRejectedValueOnce(injected) - } else if (failurePoint === 'identity') { - vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) - } else if (failurePoint === 'proof') { - vi.spyOn(store, 'proveOwner').mockRejectedValueOnce(injected) - } else if (failurePoint === 'journal') { - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle('legacy-thread'), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - } - const releaseAcquisition = vi.fn(async () => { - if (cleanup === 'throws') { - throw new Error('cleanup failed') - } - return cleanup - }) - const failingAdapter = { ...base, acquire, readOptions, releaseAcquisition } - let journalFailure = failurePoint === 'journal' ? injected : null - const perform = ( - target: AgentSessionRecordStore, - operationId: string, - fence: number | null - ) => - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: target, - adapter: failingAdapter, - openConversation: async (record) => { - const failure = journalFailure - journalFailure = null - if (failure) { - throw failure - } - return openTestAttachConversation(openTestJournalHostDatabase(root!))(record) - }, - authority: { - spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: operationId, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(operationId, fence), - now: () => NOW, - onAttached: () => {} - }) - - // A proven exit before the journal opens is answered once, as the refusal its replay gives; - // no exit was observed, so it names no situation. - const failed = perform(store, CREATE_OPERATION, null) - await (exitProven && failurePoint !== 'journal' - ? expect(failed).resolves.toEqual({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: "Codex couldn't restart. Send your message to try again." - } - }) - : expect(failed).rejects.toThrow( - exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' - )) - - const reopened = await openTestAgentSessionRecordStore(root) - const failedRecord = reopened.getRecord(SESSION) - expectSettledAttachLease(failedRecord) - expect( - reopened.listOperationRows().find((row) => row.operationId === CREATE_OPERATION)?.outcome - ).toMatchObject({ status: 'failed' }) - - await reopened.reconcileOnRestart({ - probe: async (record) => - exitProven || record.lease.ownerProcess === null - ? exitProven - ? { outcome: 'reservation-unused' } - : { outcome: 'indeterminate', reason: 'owner identity was never committed' } - : { outcome: 'identity-matched', matchedOn: ['process-start-time'] }, - now: NOW + 1 - }) - - if (exitProven) { - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, - claimStatus: 'released', - handoffStage: null, - handoffOperationId: null, - ownerProcess: null, - reservedSpawnToken: null - }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) - expectSettledAttachLease(reopened.getRecord(SESSION)) - } else if (failurePoint === 'proof' || failurePoint === 'journal') { - // A recorded owner goes to recovery, which concludes about it before the next start. - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 1, - claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', - handoffStage: 'recovering', - // The settled operation must not stay named by the lease as an in-flight transfer. - handoffOperationId: null, - reservedSpawnToken: 'spawn-a' - }) - await expect(perform(reopened, RESUME_OPERATION, 1)).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) - } else { - // No owner was recorded, and the adapter closed the stdio of anything it spawned: released, - // with no death evidence, since nothing proved one. - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, - claimStatus: 'released', - handoffStage: null, - handoffOperationId: null, - ownerProcess: null, - reservedSpawnToken: null, - deathEvidence: null - }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) - } - }) - }) -}) - -describe('the tab a create reserves', () => { - async function openStore() { - root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) - return openTestAgentSessionRecordStore(root) - } - - function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { - return performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which - // conversation this attaches to. - params: { - ...attachParams(CREATE_OPERATION, null), - ...(surfaceTabId ? { surfaceTabId } : {}) - }, - now: () => NOW, - onAttached: () => {} - }) - } - - it('takes no tab at attach, then answers a retry naming another tab with the one it was given', async () => { - const store = await openStore() - const created = await attachWith(store, 'chat-tab-1') - // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. - expect(created.ok && created.value.tabId).toBeUndefined() - expect(store.getSessionTabId(SESSION)).toBeNull() - - await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') - expect(await attachWith(store, 'chat-tab-2')).toMatchObject({ - ok: true, - value: { tabId: 'chat-tab-1' } - }) - expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts deleted file mode 100644 index 90c94306db1..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// A chat starts whatever the store stamped on its owner: the Orca runtime holding the process is -// recorded beside its identity, and the start compares the process, not the stamp. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { agentSessionRuntimeIncarnation } from '../../runtime/agent-session-runtime-attribution' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'stamped-owner-session' -const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function attachParams(): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -it('starts a chat whose recorded owner carries the runtime stamp', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-stamped-owner-')) - const store = await openTestAgentSessionRecordStore(root) - // The record as the store published it, stamp included, rather than the transition's own copy. - const commit = store.commitProcessIdentity.bind(store) - vi.spyOn(store, 'commitProcessIdentity').mockImplementation(async (input) => { - await commit(input) - const published = store.getRecord(input.sessionId) - if (!published) { - throw new Error('the committed record is missing') - } - return published - }) - const adapter: StructuredAgentSessionAdapter = { - acquire: vi.fn( - async ({ fence, spawnToken, onSpawned }) => { - const process = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken } - await onSpawned?.(process) - return { - process, - link: { - linkId: 'created-link', - handle: codexProviderHandle('thread-1'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - } - ), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } - - const attached = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(), - now: () => NOW, - onAttached: () => {} - }) - - expect(attached).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.lease.ownerProcess).toMatchObject({ - pid: 4242, - runtime: agentSessionRuntimeIncarnation() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 01dc212ccdf..dac32c5e34e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,21 +1,17 @@ import { isDeepStrictEqual } from 'node:util' -import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' +import { abortSignalReason } from '../../../shared/abort-signal-reason' import type { AgentSessionProcessIdentity, AgentSessionRecord } from '../../../shared/agent-session-record' import { AgentSessionPreSpawnError, - isAgentSessionPreSpawnError, - type StructuredAgentSessionProviderChildPhase + isAgentSessionPreSpawnError } from './structured-agent-session-adapter' import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' -import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation' import { mintStructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' /** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp * is about who holds the process, not which process it is. */ @@ -28,23 +24,6 @@ function sameOwnerProcess( return isDeepStrictEqual(storedProcess, acquiredProcess) } -/** Bookkeeping: a failure is logged, never the proven start's. */ -function handOverStartCatalogListing( - input: AttachFlowInput, - sessionId: string, - listing: AgentModelCatalogLiveListing -): void { - try { - input.onStartCatalogListing?.(listing) - } catch (error) { - input.logger.warn('saving what a started provider listed failed', { - scope: 'provider-started-catalog', - sessionId, - error - }) - } -} - /** A reservation with no process behind it is only a promise to spawn; the * adapter makes it real and the store then grants the writer. */ export async function acquireOwner( @@ -52,8 +31,7 @@ export async function acquireOwner( record: AgentSessionRecord ): Promise<{ record: AgentSessionRecord - acquisitionGeneration: string | null - providerChildPhase: StructuredAgentSessionProviderChildPhase + acquisitionGeneration: string }> { const fence = record.lease.runtimeFence const spawnToken = record.lease.reservedSpawnToken @@ -63,12 +41,14 @@ export async function acquireOwner( try { try { await input.onAcquiring?.() - // A close or Stop that landed while the attach was still reconciling launches nothing. - input.acquireSignal?.throwIfAborted() } catch (error) { throw new AgentSessionPreSpawnError(error) } const permissionMode = await input.launchPermissionMode?.(record) + // Right before the spawn: a close, Stop or quit that landed during any wait above launches nothing. + if (input.acquireSignal?.aborted) { + throw new AgentSessionPreSpawnError(abortSignalReason(input.acquireSignal)) + } const attempt = mintStructuredAgentSessionStartupAttempt({ record, identity: journalIdentityFor(record, input.params), @@ -76,12 +56,13 @@ export async function acquireOwner( spawnToken, ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), - ...(input.acquireSignal ? { signal: input.acquireSignal } : {}) + ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), + ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}), + ...(input.startupAttemptId ? { attemptId: input.startupAttemptId } : {}) }) const progress = input.onStartupAttempt?.(attempt) const acquired = await input.adapter.acquire({ ...attempt, - ...(progress ? { onOutput: progress.output } : {}), ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), onSpawned: async (process) => { progress?.spawned() @@ -93,28 +74,6 @@ export async function acquireOwner( }) } }) - const providerChildPhase = acquired.providerChildPhase ?? 'ready' - // A starting child has proven nothing: the record keeps the reservation's saved options as - // intent, never a catalog guess, and the `started` event persists what the child reports. - const options = - providerChildPhase === 'starting' - ? undefined - : await withAgentSessionCreatePhase('restore_options', input.recordPhase, async () => { - const read = { - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) - } - // Still inside the start, so the limit or a Stop ends a read the provider never answers. - return waitForPromiseWithSignal( - Promise.resolve( - input.adapter.readAcquisitionOptions - ? input.adapter.readAcquisitionOptions(read) - : readNativeSessionOptions({ adapter: input.adapter, ...read }) - ), - input.acquireSignal - ) - }) if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, @@ -125,21 +84,14 @@ export async function acquireOwner( } else if (!sameOwnerProcess(record.lease.ownerProcess, acquired.process)) { throw new Error('agent_session_ownership_unknown') } + // The process owns the lease now; a handle the provider answers with is returned by `open`. const proved = await input.store.proveOwner({ sessionId: record.sessionId, fence, ...(acquired.link ? { link: acquired.link } : {}), - now: input.now(), - ...(options ? { options } : {}) + now: input.now() }) - if (providerChildPhase === 'ready' && acquired.catalogListing) { - handOverStartCatalogListing(input, record.sessionId, acquired.catalogListing) - } - return { - record: proved, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - providerChildPhase - } + return { record: proved, acquisitionGeneration: acquired.acquisitionGeneration } } catch (error) { if (isAgentSessionPreSpawnError(error)) { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts deleted file mode 100644 index 6ab54a89eec..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import type { - AgentSessionAcquisition, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { StructuredAgentDefinition } from './structured-agent-definition' - -const LOCAL: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' -} - -function identity(sessionId: string, agent: string): AgentSessionJournalIdentity { - return { - sessionId, - workspaceId: 'workspace-1', - hostId: 'local', - agent, - providerHandle: claudeProviderHandle('provider-session-1', null) - } -} - -function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition { - return { - process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle('provider-session-1', null), - origin: 'created', - mintedAtFence: fence, - observedAt: 1 - } - } -} - -function fakeAdapter( - overrides: Partial = {} -): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn(), - ...overrides - } -} - -/** An agent this build does not ship, declared the way a new adapter would declare itself. */ -const PILOT: StructuredAgentDefinition = { - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME', - capabilities: { - rewind: false, - compact: false, - threadGoal: false, - contextUsage: false, - imagePrompts: false, - steering: 'queue', - approvalEnforcement: 'orca' - }, - restingOptions: { - acceptsKey: () => false, - fallbackModels: () => null, - effortDefaultsToModel: false - } -} - -/** A double with every method Claude's and Codex's declarations need. */ -function declaringAdapter( - overrides: Partial = {} -): StructuredAgentSessionAdapter { - return fakeAdapter({ - compact: vi.fn(), - changeThreadGoal: vi.fn(), - rewind: vi.fn(), - recoverRewind: vi.fn(), - ...overrides - }) -} - -function claudeAndCodex( - adapters: { claude?: StructuredAgentSessionAdapter; codex?: StructuredAgentSessionAdapter } = {} -): StructuredAgentRegistry { - return new StructuredAgentRegistry([ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude ?? declaringAdapter() }, - { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex ?? declaringAdapter() } - ]) -} - -describe('StructuredAgentSessionAdapterRouter registry', () => { - it('routes a registered agent the router has no code for, and refuses an unregistered one', async () => { - const pilot = fakeAdapter() - const router = new StructuredAgentSessionAdapterRouter( - new StructuredAgentRegistry([{ definition: PILOT, adapter: pilot }]), - async () => {} - ) - - expect(router.supportsCreate(LOCAL, 'grok')).toBe(true) - expect(router.supportsCreate(LOCAL, 'claude')).toBe(false) - await router.acquire({ identity: identity('session-1', 'grok'), fence: 1, spawnToken: 's-1' }) - expect(pilot.acquire).toHaveBeenCalledOnce() - await expect( - router.acquire({ identity: identity('session-2', 'claude'), fence: 1, spawnToken: 's-2' }) - ).rejects.toThrow('structured sessions do not support claude') - }) - - it('refuses two registrations for one agent', () => { - expect( - () => - new StructuredAgentRegistry([ - { definition: PILOT, adapter: fakeAdapter() }, - { definition: PILOT, adapter: fakeAdapter() } - ]) - ).toThrow('structured agent grok is registered twice') - }) - - it.each([ - ['compact', 'compact'], - ['threadGoal', 'changeThreadGoal'], - ['rewind', 'rewind'], - ['rewind', 'recoverRewind'] - ] as const)('refuses a declared %s whose adapter has no %s', (capability, method) => { - const declared: StructuredAgentDefinition = { - ...PILOT, - capabilities: { ...PILOT.capabilities, [capability]: true } - } - const adapter = declaringAdapter({ [method]: undefined }) - - expect(() => new StructuredAgentRegistry([{ definition: declared, adapter }])).toThrow( - `structured agent grok declares a capability its adapter has no ${method} for` - ) - expect(() => new StructuredAgentRegistry([{ definition: PILOT, adapter }])).not.toThrow() - }) - - it('answers what each registered agent declares, and nothing for an unregistered one', () => { - const agents = claudeAndCodex() - - expect(agents.capabilities('codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) - expect(agents.capabilities('claude')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) - expect(agents.capabilities('grok')).toBeNull() - expect(agents.definition('grok')).toBeNull() - expect(agents.definitions()).toEqual([CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) - }) - - it('lets a session narrow a declared rewind but never widen an undeclared one', async () => { - const narrowing = declaringAdapter({ - rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) - }) - const widening = declaringAdapter({ rewindSupport: () => ({ supported: true }) }) - const router = new StructuredAgentSessionAdapterRouter( - claudeAndCodex({ codex: narrowing, claude: widening }), - async () => {} - ) - const unsupported = { supported: false, reason: 'unsupported' } - - expect(router.rewindSupport('session-1', 'codex')).toEqual({ - supported: false, - reason: 'history-not-paginated' - }) - // Claude declares no rewind; its adapter's answer cannot claim one, at rest or live. - expect(router.rewindSupport('session-1', 'claude')).toEqual(unsupported) - await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) - expect(router.rewindSupport('session-1')).toEqual(unsupported) - expect(router.rewindSupport('session-2', 'grok')).toEqual(unsupported) - }) -}) - -describe('structured agent definitions', () => { - it('declares what Claude and Codex structured chats already did', () => { - expect(CLAUDE_STRUCTURED_AGENT.capabilities).toEqual({ - rewind: false, - compact: true, - threadGoal: false, - contextUsage: true, - imagePrompts: true, - steering: 'inject', - approvalEnforcement: 'provider' - }) - expect(CODEX_STRUCTURED_AGENT.capabilities).toEqual({ - rewind: true, - compact: true, - threadGoal: true, - contextUsage: true, - imagePrompts: true, - steering: 'inject', - approvalEnforcement: 'provider' - }) - }) - - it('keeps each agent’s resting option rules with its definition', () => { - const claude = CLAUDE_STRUCTURED_AGENT.restingOptions - const codex = CODEX_STRUCTURED_AGENT.restingOptions - expect(claude.fallbackModels()?.length).toBeGreaterThan(0) - expect(codex.fallbackModels()).toBeNull() - expect(claude.effortDefaultsToModel).toBe(true) - expect(codex.effortDefaultsToModel).toBe(false) - expect(claude.acceptsKey('model')).toBe(true) - expect(codex.acceptsKey('model')).toBe(true) - expect(claude.acceptsKey('no-such-option')).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 8545e6009a9..8a5a7af3339 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -29,7 +29,8 @@ function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition origin: 'created', mintedAtFence: fence, observedAt: 1 - } + }, + acquisitionGeneration: `generation-${fence}` } } @@ -38,6 +39,7 @@ function adapterOf( ): StructuredAgentSessionAdapter { return { acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), + open: vi.fn(async () => ({ reportedOptions: { model: '' } })), releaseAcquisition, hasOwnedAcquisition: () => true, dispatch: vi.fn(), @@ -63,30 +65,6 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { }) }) -describe('StructuredAgentSessionAdapterRouter.readAcquisitionOptions', () => { - it('keeps the existing live read for providers without acquisition options', async () => { - const claude = adapterOf(vi.fn(async () => true)) - claude.readOptions = vi.fn(async () => ({ - current: { model: 'reported-model', effort: 'high' }, - models: [] - })) - const router = claudeAndCodexRouter( - { claude, codex: adapterOf(vi.fn(async () => false)) }, - async () => {} - ) - await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) - - await expect( - router.readAcquisitionOptions({ - sessionId: 'session-1', - fence: 1, - priorOptions: { model: 'saved-model', personality: 'concise' } - }) - ).resolves.toEqual({ model: 'reported-model', effort: 'high', personality: 'concise' }) - expect(claude.readOptions).toHaveBeenCalledOnce() - }) -}) - describe('StructuredAgentSessionAdapterRouter.closeSession', () => { it('retains the owner after an unproven close so a later retry reaches the same adapter', async () => { const claude = adapterOf(vi.fn(async () => true)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 46c44731945..1ec52f06259 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -6,7 +6,6 @@ import type { } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { StructuredAgentRegistration, StructuredAgentRegistry @@ -53,13 +52,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return releaseSessionRoute(input, this.routes, () => this.adapters()) } - open: NonNullable = (input) => { - const adapter = this.owner(input.sessionId) - if (!adapter.open) { - return Promise.reject(new Error('The provider cannot open a published child.')) - } - return adapter.open(input) - } + open: StructuredAgentSessionAdapter['open'] = (input) => this.owner(input.sessionId).open(input) dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) @@ -124,9 +117,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.holdsLiveProviderProcess?.(sessionId, acquisitionGeneration) ?? false - startUnavailable: NonNullable = (sessionId) => - this.liveOwnerOrNull(sessionId)?.startUnavailable?.(sessionId) - stopEndsSession = (sessionId: string): boolean => this.liveOwnerOrNull(sessionId)?.stopEndsSession?.(sessionId) ?? false @@ -159,9 +149,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi setOption: StructuredAgentSessionAdapter['setOption'] = (input) => this.owner(input.sessionId).setOption(input) - startAnswered = (sessionId: string): boolean | undefined => - this.liveOwnerOrNull(sessionId)?.startAnswered?.(sessionId) - prepareReadOptions = (input: { sessionId: string; fence: number }) => this.liveOwnerOrNull(input.sessionId)?.prepareReadOptions?.(input) @@ -173,17 +160,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return reader(input) } - readAcquisitionOptions = (input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }) => { - const adapter = this.owner(input.sessionId) - return adapter.readAcquisitionOptions - ? adapter.readAcquisitionOptions(input) - : readNativeSessionOptions({ adapter, ...input }) - } - readOptionRestoreFailures = (sessionId: string): readonly string[] => this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts deleted file mode 100644 index daa7d202842..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRefusal, - AgentSessionAcquisitionRootExitObservedError -} from './structured-agent-session-adapter' -import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' - -describe('failed agent-session acquisition cleanup', () => { - it('names a failure exit-proven after proven cleanup, keeping its diagnostic and cause', async () => { - const cause = new Error('proof failed') - const thrown = await rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => true) }, - 'session-1', - cause - ).catch((error: unknown) => error) - - expect(thrown).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) - expect(thrown).toMatchObject({ message: 'proof failed', cause }) - }) - - it.each([ - ['a refusal', new AgentSessionAcquisitionRefusal('not signed in')], - ['a root exit', new AgentSessionAcquisitionRootExitObservedError(new Error('exited'))], - ['a host store code', new Error('agent_session_checkpoint_stale')] - ])('keeps %s that already names its verdict after proven cleanup', async (_label, cause) => { - await expect( - rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => true) }, - 'session-1', - cause - ) - ).rejects.toBe(cause) - }) - - it('reports unproven exit when cleanup returns false', async () => { - await expect( - rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => false) }, - 'session-1', - new Error('proof failed') - ) - ).rejects.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - }) - - it('keeps a first-hand root exit that cleanup observed, with the provider diagnostic', async () => { - const cause = new Error('proof failed') - const exit = new AgentSessionAcquisitionRootExitObservedError( - new Error('claude stream-json exited (code 1): crashed') - ) - const error = await rethrowAfterAgentSessionAcquisitionCleanup( - { - releaseAcquisition: vi.fn(async () => { - throw exit - }) - }, - 'session-1', - cause - ).catch((thrown: unknown) => thrown) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') - expect((error as Error).cause).toMatchObject({ errors: [cause, exit] }) - }) - - it('reports unproven exit when cleanup throws', async () => { - const error = await rethrowAfterAgentSessionAcquisitionCleanup( - { - releaseAcquisition: vi.fn(async () => { - throw new Error('cleanup failed') - }) - }, - 'session-1', - new Error('proof failed') - ).catch((cause: unknown) => cause) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).toMatchObject({ cause: expect.any(AggregateError) }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 3eaf8c236d6..baabf4eb1f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -1,8 +1,5 @@ import type { AiVaultConversationCopy } from '../../../shared/ai-vault-resume-preparation' -import type { - AgentSessionAccountKind, - AgentSessionUnavailable -} from '../../../shared/agent-session-availability' +import type { AgentSessionAccountKind } from '../../../shared/agent-session-availability' import type { AgentSessionRewindReason, AgentSessionRewindSupport @@ -51,6 +48,7 @@ export type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { @@ -59,7 +57,10 @@ import type { } from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' -import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' +import type { + StructuredAgentSessionProviderOpen, + StructuredAgentSessionProviderOpenResult +} from './structured-agent-session-provider-open-contract' /** A live options read, plus what the child listed for its account's saved catalog. The host * saves that listing and strips it before answering; an adapter whose listing reaches the @@ -109,22 +110,14 @@ export { AgentSessionAcquisitionRootExitObservedError } from './structured-agent-session-acquisition-errors' -/** What a reservation turns into once something is actually running under it: - * the process the host can probe, and the provider handle it was minted with. */ +/** The owned process returned before protocol construction begins. */ export type AgentSessionAcquisition = { process: AgentSessionProcessIdentity + /** Only a handle the adapter already holds at spawn; protocol handles come from open. */ link?: AgentSessionProviderHandleLink /** Host-local identity for this exact provider child, distinct even when the durable fence is * reused by a superseding acquisition. */ - acquisitionGeneration?: string - /** `starting`: published at spawn, proven later by a `started` event. Absent means `ready`, an - * acquire that ran the whole handshake before answering; that bridge ends once every adapter - * publishes at spawn. Either way the host holds input until the child is `ready`. */ - providerChildPhase?: StructuredAgentSessionProviderChildPhase - /** What a `ready` child listed at its start, with the configured default its start resolved; - * the host saves it once. A `starting` child (Claude) hands its listing on `started` and its - * resolved default on the settings readback's `options-reported`. */ - catalogListing?: AgentModelCatalogLiveListing + acquisitionGeneration: string } /** A refusal before spawn that a person can act on; the site that refused names it. */ @@ -209,43 +202,24 @@ export type StructuredAgentSessionEndedEvent = { acquisitionGeneration: string /** Host receipt of the child exit: the end time of a turn it interrupted. */ observedAt?: number - /** The provider ended before it finished starting, so resuming it would repeat the failure. */ - startupUnproven?: true /** The provider ended before it answered its start, so it ran nothing it was handed. */ startupUnanswered?: true } -/** The child a publish-first acquire handed over has now proven its start: startup facts applied. - * What it reports from here on is fact, not a catalog guess. */ -export type StructuredAgentSessionStartedEvent = { - /** The handle supplied when the provider finishes starting. */ - link?: AgentSessionProviderHandleLink - type: 'started' - sessionId: string - fence: number - acquisitionGeneration: string - /** What the child proved, snapshotted by the adapter from what startup already read. The host - * handles this inside the session's serialized step, so it must not ask the CLI. */ - reportedOptions: AgentSessionOptionsResult['current'] - /** What the child listed at startup, saved as its account's catalog even if no view reads it. */ - catalogListing?: AgentModelCatalogLiveListing -} - -/** What a child that already proved its start reports later, such as an optional read that came - * after `started`: persisted as the start's report is. Never delays a start. */ -export type StructuredAgentSessionOptionsReportedEvent = Omit< - StructuredAgentSessionStartedEvent, - 'type' | 'link' -> & { type: 'options-reported' } +/** An optional report after open; it never delays readiness. */ +export type StructuredAgentSessionOptionsReportedEvent = + StructuredAgentSessionProviderOpenResult & { + type: 'options-reported' + sessionId: string + fence: number + acquisitionGeneration: string + } export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent - | StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent -/** Whether the provider child behind an acquisition has proven its start. A publish-first - * acquire hands over a `starting` child, which the host gives no input until the `started` - * lifecycle event flips it. */ +/** Whether the host's open has proven the child ready for input. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' /** The host's startup attempt (`structured-agent-session-startup-attempt`), which the host always @@ -273,7 +247,7 @@ export type StructuredAgentSessionSetOptionInput = { export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { /** Opens the published process under the host's startup signal. */ - open?: StructuredAgentSessionProviderOpen + open: StructuredAgentSessionProviderOpen /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ @@ -294,6 +268,10 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string clientMessageId: string body: AgentJournalMessageItem + /** Immutable choices of this submission; adapters apply them only to this dispatch. */ + executionOptions?: AgentSessionExecutionOptions + /** Host-local cancellation while choices are applied before prompt admission. */ + signal?: AbortSignal fence: number /** Host clock on the submission row this send came from; the origin the turn * it opens records as `requestedAt`. */ @@ -350,6 +328,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** True when the journal records a goal, whatever its status: a `set` must * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean + signal?: AbortSignal }): Promise<{ ok: true } | { ok: false; rejected: string }> /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. * `stillRunning` is the provider's own answer that one survived its stop; a throw leaves the @@ -374,9 +353,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** The adapter's own child for this exact acquisition has a pid and its root exit has not been * seen: first-hand proof of life for lease renewal. Absent or false falls back to a PID probe. */ holdsLiveProviderProcess?(sessionId: string, acquisitionGeneration: string): boolean - /** Why the session's running child can take no turn, as its own start found; undefined when - * unknown or no child runs. Lives and dies with that child. */ - startUnavailable?(sessionId: string): AgentSessionUnavailable | undefined /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined @@ -396,9 +372,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & setOption( input: StructuredAgentSessionSetOptionInput ): Promise>> - /** False while the live child has not answered its start, so it has run nothing it was handed. - * Absent or undefined reads as answered. */ - startAnswered?(sessionId: string): boolean | undefined /** Fetch off the lane, then apply the result under the same child's fence. */ prepareReadOptions?(input: { sessionId: string @@ -408,16 +381,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string fence: number }): Promise - /** Effective options already known after acquisition, without discovering picker choices. */ - readAcquisitionOptions?(input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }): - | Promise> | undefined> - | Readonly> - | undefined - /** Option keys skipped after a provider rejected their persisted restore value. */ + /** Saved option keys the running child could not take (a model its provider retired). */ readOptionRestoreFailures?(sessionId: string): readonly string[] /** Provider history for restart reconciliation, bounded to what the provider * recorded after the journal's last committed item. Only the adapter can say diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index e8286aa43ec..7b264c8fc4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -119,9 +119,13 @@ function attachParams( function adapter(): StructuredAgentSessionAdapter { return { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link', @@ -237,6 +241,7 @@ describe('adopting a provider conversation on create', () => { } const sessionAdapter = adapter() vi.mocked(sessionAdapter.acquire).mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `generation-${fence}`, process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index f61746cb05e..fa4ef169c00 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -1,9 +1,9 @@ // Giving a session its provider child back. // // Only work starts one: the delivery loop for a queued message, and the few operations that need -// the provider itself — never a view, and never the app launching. It runs inside the session's -// serialize, with the attach it is given, so the eligibility it reads is the one the attach acts -// on. +// the provider itself — never a view, and never the app launching. It runs outside the session's +// serialize as one registered flight (`structured-agent-session-start-flight`), so a message sent +// meanwhile is accepted at once; each of its writes is a fenced commit under the serialize. import { providerDiagnosticOf, @@ -14,10 +14,7 @@ import { argumentProblemOf } from '../structured-agent-arguments-error' import { agentSessionRefusalFromReference, readAgentSessionRefusalReference, - refuse, - refuseUnclassified, - type AgentSessionRefusalDetailsByCode, - type AgentSessionWireRefusalCode + refuseUnclassified } from '../../../shared/agent-session-wire-refusals' import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import type { @@ -25,20 +22,23 @@ import type { AgentSessionMutationResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { randomUUID } from 'node:crypto' +import { attachStructuredAgentSessionForStart } from './structured-agent-session-attach-orchestration' +import type { StructuredAgentSessionAttachLock } from './structured-agent-session-attach-lock' +import { structuredAgentSessionStartFlightLock } from './structured-agent-session-start-flight' +import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt-contract' import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' -import { hostCanStartRecord } from './structured-agent-session-provider-support' import { joinClosingStructuredAgentSessionChild, releaseLeaseOfEndedStructuredAgentSessionChild } from './structured-agent-session-child-close' export { joinClosingStructuredAgentSessionChild } import { - structuredAgentSessionResumeOperationId, - structuredAgentSessionResumeParams -} from './structured-agent-session-resume-eligibility' + refusedStructuredAgentSessionStart, + structuredAgentSessionStartParams +} from './structured-agent-session-start-eligibility' /** A resume answers with the attach's own refusal, verdict and all, so the asker can tell a lease * someone else is settling from an owner that will not come back. */ @@ -60,121 +60,168 @@ export type StructuredAgentSessionResumeOutcome = /** The attach's caller key: the ledger row a start settles is Orca's own. */ const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' -/** - * Gives the session a provider child if it has none, for a caller inside its serialize — which is - * what makes "if it has none" exact: two askers run this in turn, and the second finds the first - * one's child. A failed start leaves the next asker to make its own. - */ -export async function ensureStructuredAgentSessionAgent( +export type StructuredAgentSessionStartClaim = { + outcome: Promise +} + +/** Claims the effect in the ordered decision that needs it; waiting happens outside that lane. */ +export function claimStructuredAgentSessionAgent( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor?: string +): StructuredAgentSessionStartClaim { + const session = context.sessions.get(sessionId) + const child = session?.child + if ( + startedFor && + !session?.journal + .submissions() + .some( + (submission) => + submission.clientMessageId === startedFor && isQueuedAgentJournalSubmission(submission) + ) + ) { + return { outcome: Promise.resolve({ ok: false, aborted: true, refusal: startFailedRefusal() }) } + } + if (child && !child.close) { + return { outcome: Promise.resolve({ ok: true }) } + } + return { + outcome: + context.runtimeState.startFlights.get(sessionId)?.outcome ?? + registerStart(context, sessionId, startedFor) + } +} + +/** Convenience for callers outside the lane; the claim itself never awaits its execution. */ +export async function startStructuredAgentSessionAgent( context: StructuredAgentSessionAttachContext, sessionId: string, startedFor?: string ): Promise { - // A child a stop began closing takes no input, and none may start beside it: the start waits on - // that close, and is refused while its exit stays unverifiable. - const closed = await joinClosingStructuredAgentSessionChild(context, sessionId) - if (!closed.ok) { - return closed - } - if (context.sessions.get(sessionId)?.child) { - return { ok: true } - } - const started = await startStructuredAgentSessionAgent(context, sessionId, startedFor) - if (!started.ok || context.sessions.get(sessionId)?.child) { - return started - } - return refuseResume( - 'agent_session_ownership_unknown', - { reason: 'noProviderChild' }, - 'The session attached without a provider child to write to.' + const claim = await context.serialize(sessionId, async () => + claimStructuredAgentSessionAgent(context, sessionId, startedFor) ) + return claim.outcome } /** The same, for an operation's admission: a start that throws is that operation's refusal. */ -export function ensureStructuredAgentSessionAgentForOperation( +export function startStructuredAgentSessionAgentForOperation( context: StructuredAgentSessionAttachContext, sessionId: string ): Promise { - return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { - // The error is Orca's own and goes to the log; the refusal says only that the start failed. - context.deps.logger.warn('starting the agent for an operation failed', { - scope: 'operation-agent-start', - sessionId, - error - }) - return { - ok: false, - refusal: refuseUnclassified( - 'agent_session_owner_restart_failed', - agentSessionWriteNoticeEnglish(['restartFailed']) - ) - } - }) + return context + .serialize(sessionId, async () => claimOperationStart(context, sessionId)) + .then((claim) => claim.outcome) } -async function startStructuredAgentSessionAgent( +export function claimOperationStart( + context: StructuredAgentSessionAttachContext, + sessionId: string +): StructuredAgentSessionStartClaim { + const claim = claimStructuredAgentSessionAgent(context, sessionId) + return { + outcome: claim.outcome.catch((error: unknown) => { + context.deps.logger.warn('starting the agent for an operation failed', { + scope: 'operation-agent-start', + sessionId, + error + }) + return { ok: false, refusal: startFailedRefusal() } + }) + } +} + +/** Inside the register step, before any wait: the controller a Stop, close or quit aborts, the + * startup clock, and quit's drain all cover the start from here. */ +function registerStart( context: StructuredAgentSessionAttachContext, sessionId: string, startedFor: string | undefined ): Promise { + const { runtimeState } = context + const attemptId = randomUUID() + const acquire = runtimeState.acquireAborts.begin(sessionId) + runtimeState.startupAttempts.begin(sessionId, attemptId) + const lock = structuredAgentSessionStartFlightLock({ + sessionId, + attemptId, + signal: acquire.signal, + flights: runtimeState.startFlights, + serialize: context.serialize + }) + const outcome = context.tasks.trackAttach( + Promise.resolve() + .then(() => runStart(context, sessionId, startedFor, lock, attemptId)) + .catch((error: unknown) => { + if (!acquire.signal.aborted) { + throw error + } + const expired = isStructuredAgentSessionStartupExpired(acquire.signal.reason) + return { + ok: false as const, + refusal: startFailedRefusal(), + ...(expired ? { expired: true as const } : { aborted: true as const }) + } + }) + .finally(() => { + acquire.end() + runtimeState.startFlights.end(sessionId, attemptId) + runtimeState.startupAttempts.abandon(sessionId, attemptId) + }) + ) + runtimeState.startFlights.register(sessionId, { + attemptId, + outcome, + abort: acquire.abort + }) + return outcome +} + +async function runStart( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor: string | undefined, + lock: StructuredAgentSessionAttachLock, + attemptId: string +): Promise { + // A child a stop began closing takes no input, and none may start beside it: the start waits on + // that close, and is refused while its exit stays unverifiable. + const closed = await joinClosingStructuredAgentSessionChild(context, sessionId, lock) + if (!closed.ok) { + return closed + } const callerKey = AGENT_START_CALLER_KEY // The record is read only once this host has adjudicated it and recovery resolution has // concluded about any owner a failed attempt left in `recovering`, so the eligibility below sees - // the lease the resolver handed back. - const unreconciled = await context.reconcileLeases(sessionId) + // the lease the resolver handed back. Both probe outside; each commits in the store's own + // transaction, against the owner identity it probed. + const unreconciled = await lock.wait(context.reconcileLeases(sessionId)) if (unreconciled) { return { ok: false, refusal: unreconciled } } - await context.runtimeState.resolveRecovery(sessionId) - // A start needs the lease released; a release the exit's own wind-down could not write is - // re-derived from this host's proof of that exit, never refused on. - await releaseLeaseOfEndedStructuredAgentSessionChild(context, sessionId) - const record = context.deps.store.getRecord(sessionId) - if (!record) { - return refuseResume( - 'agent_session_identity_required', - { reason: 'recordMissing' }, - 'No structured session exists by that id.' - ) - } - if (!hostCanStartRecord(context.deps, record)) { - return refuseResume( - 'structured_agent_session_unsupported', - { reason: 'hostUnsupported' }, - 'This execution host cannot resume the requested structured agent session.' - ) - } - const params = structuredAgentSessionResumeParams( - record, - structuredAgentSessionResumeOperationId(context.now()), - context.sessions.get(sessionId)?.lastEndedChild - ) - if (!params) { - return record.lease.unreconciled - ? refuseResume( - 'execution_owner_reconciling', - { reason: 'hostReconciling' }, - 'This host has not yet adjudicated the session lease.' - ) - : record.lease.claimStatus === 'conflicted' - ? refuseResume( - 'agent_session_conflict', - { reason: 'claimConflicted' }, - terminalOwnerRefusalMessage(record.lease) - ) - : refuseResume( - 'agent_session_ownership_unknown', - { reason: 'notResumable' }, - 'The session lease is not one this host may resume.' - ) + await lock.wait(context.runtimeState.resolveRecovery(sessionId)) + const eligible = await lock.commit(async () => { + // A start needs the lease released; a release the exit's own wind-down could not write is + // re-derived from this host's proof of that exit, never refused on. + await releaseLeaseOfEndedStructuredAgentSessionChild(context, sessionId) + return structuredAgentSessionStartParams(context, sessionId, startedFor) + }) + if (!eligible.ok) { + return eligible } + const { params, startupOptions } = eligible let attached: AgentSessionMutationResult let acquisitionError: unknown let aborted = false let expired = false + let published = false try { - attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { + attached = await attachStructuredAgentSessionForStart(context, callerKey, params, { + lock, + startupAttemptId: attemptId, ...(startedFor === undefined ? {} : { startedFor }), + ...(startupOptions ? { startupOptions } : {}), onAcquisitionFailed: (error) => { acquisitionError = error }, @@ -183,6 +230,9 @@ async function startStructuredAgentSessionAgent( }, onStartupExpired: () => { expired = true + }, + onPublished: () => { + published = true } }) } catch (error) { @@ -201,9 +251,23 @@ async function startStructuredAgentSessionAgent( } throw error } - return attached.ok + if (!attached.ok) { + return withDiagnostic(attached.refusal, acquisitionError, { aborted, expired }) + } + return published ? { ok: true } - : withDiagnostic(attached.refusal, acquisitionError, { aborted, expired }) + : refusedStructuredAgentSessionStart( + 'agent_session_ownership_unknown', + { reason: 'noProviderChild' }, + 'The session attached without a provider child to write to.' + ) +} + +function startFailedRefusal(): AgentSessionWireRefusal { + return refuseUnclassified( + 'agent_session_owner_restart_failed', + agentSessionWriteNoticeEnglish(['restartFailed']) + ) } function withDiagnostic( @@ -247,11 +311,3 @@ function settledResumeRefusal( context.deps.store.getRecord(sessionId) ) } - -function refuseResume( - code: C, - details: NoInfer, - message: string -): StructuredAgentSessionResumeOutcome { - return { ok: false, refusal: refuse(code, details, message) } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 0cf996f01c6..0c9348eb073 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -100,8 +100,7 @@ function exitBeforeProof(): Promise { acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } @@ -118,7 +117,6 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -135,6 +133,7 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: () => new Promise(() => {}), acquire, releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index b9eebea23eb..3a4df8d5ef6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -1,11 +1,10 @@ -// A Claude chat whose Claude is not running offers the `/` commands and skills Claude would read -// from its folders on the host that runs it: after a /clear, after a relaunch, as files change. +// A Claude chat whose Claude is not running offers the `/` commands Claude itself lists in the +// chat's folder, falling back to what its folders hold until Claude can answer. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionSlashCommand, AgentSessionSubscribeEvent @@ -22,11 +21,9 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { StructuredAgentSessionHost } from './structured-agent-session-host' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { - HOST_TEST_LOCATION, HOST_TEST_NOW, HOST_TEST_SESSION as SESSION, hostTestAttachParams, - hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -50,7 +47,9 @@ function adapter(catalog: ClaudeAtRestCommandCatalog): StructuredAgentSessionAda return { supportsLocation: () => true, supportsCreate: () => true, + open: async () => ({ reportedOptions: {} }), acquire: vi.fn(async (input) => ({ + acquisitionGeneration: `generation-${input.fence}`, process: { hostId: 'local', pid: 4001, @@ -155,158 +154,7 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -describe("a Claude chat whose Claude isn't running shows the `/` surface from its folders", () => { - it('after /clear, before the fresh agent context starts', async () => { - const cleared = await host.conversationCommand(caller, { - command: 'clear', - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId: SESSION, - fields: { command: 'clear' } - }) - } - }) - expect(cleared).toMatchObject({ ok: true }) - const replacement = SESSION - const menu = await menuOf(replacement) - expect(namesOf(menu, 'skill')).toEqual(['review-pr']) - // Everything the menu offered before a list existed, then what the folders add. - expect(namesOf(menu, 'command')).toEqual(['model', 'effort', 'clear', 'compact', 'deploy']) - }) - - it('keeps a slow scan once it answers, never a staler one after a newer, and runs at most two', async () => { - const pending: ((skills: string[]) => void)[] = [] - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspace, - now: () => clock, - discover: () => - new Promise((resolve) => { - pending.push((names) => - resolve({ - skills: names.map((name) => ({ - id: name, - name, - description: null, - providers: ['claude'], - sourceKind: 'repo', - sourceLabel: 'repo', - rootPath: workspace, - directoryPath: workspace, - skillFilePath: workspace, - installed: true, - updatedAt: null - })), - sources: [], - scannedAt: clock - }) - ) - }) - }) - const record = store.getRecord(SESSION)! - const skills = () => namesOf(catalog.read(record) ?? null, 'skill') - const changed = vi.fn() - catalog.onChange(changed) - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(1)) - // Overdue: a second scan starts beside the first; a third never does while both run. - clock += 30_000 - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(2)) - clock += 30_000 - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(pending).toHaveLength(2) - // The second answers first; the first, older one landing after it changes nothing. - pending[1](['newer']) - await vi.waitFor(() => expect(skills()).toEqual(['newer'])) - pending[0](['older']) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(skills()).toEqual(['newer']) - expect(changed).toHaveBeenCalledTimes(1) - // A slow scan is not thrown away: the next one, however long it takes, is kept. - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(3)) - clock += 35_000 - pending[2](['slow']) - await vi.waitFor(() => expect(skills()).toEqual(['slow'])) - }) - - it('waits out the window from when a scan lands, even one that failed', async () => { - let calls = 0 - let failNext = true - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspace, - now: () => clock, - discover: async (args) => { - calls += 1 - // Each scan takes 15 s of the clock, longer than the window. - clock += 15_000 - if (failNext) { - failNext = false - throw new Error('unreadable') - } - const { discoverSkills } = await import('../../skills/discovery') - return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true }) - } - }) - const record = store.getRecord(SESSION)! - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - catalog.read(record) - await vi.waitFor(() => expect(warned).toHaveBeenCalledOnce()) - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(calls).toBe(1) - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - catalog.read(record) - await vi.waitFor(() => - expect(namesOf(catalog.read(record) ?? null, 'skill')).toEqual(['review-pr']) - ) - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - 1 - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(calls).toBe(2) - } finally { - warned.mockRestore() - } - }) - - it('after a relaunch, with nothing remembered from before it', async () => { - await host.flushAllStreamedEvents() - await openHost() - const menu = await menuOf(SESSION) - expect(namesOf(menu, 'skill')).toEqual(['review-pr']) - expect(namesOf(menu, 'command')).toContain('deploy') - }) - - it('with a command added on disk, pushed to a pane already showing the chat', async () => { - let commands: AgentSessionSlashCommand[] | null | undefined - await host.subscribe({ - id: 'pane', - sessionId: SESSION, - emit: (event) => { - if ('commands' in event) { - commands = event.commands - } - } - }) - await vi.waitFor(() => expect(namesOf(commands ?? null, 'command')).toContain('deploy')) - await writeCommand(join(workspace, '.claude', 'commands'), 'frontend/test.md') - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - // Anything that reads the surface after the window scans again; the pane is told the answer. - host.readCommands(SESSION) - await vi.waitFor(() => - expect(namesOf(commands ?? null, 'command')).toEqual( - expect.arrayContaining(['deploy', 'frontend:test']) - ) - ) - }) - +describe("a Claude chat whose Claude isn't running lists Claude's own `/` commands", () => { it('as Claude itself lists it in the chat’s folder, from the folders until it can, and keeps its list through a failed refresh', async () => { const asked: ListingCall[] = [] const failed = '{"type":"control_response","response":{"subtype":"error"}}\n' @@ -463,24 +311,4 @@ describe("a Claude chat whose Claude isn't running shows the `/` surface from it await new Promise((resolve) => setTimeout(resolve, 20)) expect(asked).toHaveLength(settledCalls + 1) }) - - it('from the folders of the host that runs it, never another host’s', async () => { - const remoteWorkspace = join(directory, 'remote-workspace') - await writeSkill(join(remoteWorkspace, '.claude', 'skills'), 'remote-only') - const remote = catalogFor(remoteWorkspace) - const record = store.getRecord(SESSION)! - remote.read(record) - await vi.waitFor(() => - expect(namesOf(remote.read(record) ?? null, 'skill')).toEqual(['remote-only']) - ) - // A chat this host does not run is answered by nothing here, not by this host's folders. - const local = catalogFor(workspace) - const elsewhere = { - ...record, - location: { ...HOST_TEST_LOCATION, executionHostId: 'ssh:box' as const } - } - local.read(elsewhere) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(local.read(elsewhere)).toBeUndefined() - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index a8368d06789..2dfbf69f923 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -87,3 +87,33 @@ export function endStructuredAgentSessionReleasedChild( // The released acquisition was the live generation: readers and the queued-card drain re-derive. context.generationEnded?.(sessionId) } + +/** A reservation that support drift invalidated, released as processless. */ +export async function settleUnsupportedReservation( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise { + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + return + } + try { + await input.store.settleFailedAcquisition({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + spawnToken, + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { + status: 'failed', + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' }, + message: 'Structured session support changed before the provider could start.' + }, + exitProof: 'processless', + now: input.now() + }) + } catch (error) { + throw new AggregateError([error], 'agent session unsupported reservation settlement failed') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index c8bd949b561..f45d9bdddfc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -1,14 +1,14 @@ import { refuse } from '../../../shared/agent-session-wire-refusals' -import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure' +import { + settlePostAcquisitionAttachFailure, + settleUnsupportedReservation +} from './structured-agent-session-attach-failure' import { failedAcquisitionRefusal, failedAcquisitionSettlement, preSpawnFailureInWords } from './structured-agent-session-failed-create-refusal' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionProviderChildPhase -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' // The host supplies owner authority; this flow reserves, proves, and publishes the session. import type { @@ -54,9 +54,12 @@ import type { StructuredAgentSessionStartupAttempt } from './structured-agent-se import type { StructuredAgentSessionStartupProgress } from './structured-agent-session-startup-attempt' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +import { + heldStructuredAgentSessionAttachLock, + type StructuredAgentSessionAttachLock +} from './structured-agent-session-attach-lock' -export type AttachedOwner = 'acquired' | 'kept' +export type AttachedOwner = 'acquired' | 'kept' | 'none' export type AttachFlowInput = { store: AgentSessionRecordStore @@ -71,25 +74,32 @@ export type AttachFlowInput = { recordPhase?: AgentSessionCreatePhaseRecorder /** Aborted when a close, or a Stop admitted now, must not wait behind this attach's acquire. */ acquireSignal?: AbortSignal + /** How the steps below meet the session's serialize; absent, the caller holds it. */ + lock?: StructuredAgentSessionAttachLock + /** The id the host registered this start under; see `StructuredAgentSessionStartupAttempts`. */ + startupAttemptId?: string /** The attempt this attach's acquire runs under, minted before the adapter is called; what it - * answers learns of the child's spawn and output. */ + * answers learns of the child's spawn. */ onStartupAttempt?: ( attempt: StructuredAgentSessionStartupAttempt ) => StructuredAgentSessionStartupProgress - /** A completed acquisition's start listing. */ - onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void + startupOptions?: Readonly> /** The permission mode a start of this record launches with; absent or undefined for none. */ launchPermissionMode?: ( record: AgentSessionRecord ) => Promise /** Publishes the journal before clients can send against the new owner. `owner` says whether - * this attach spawned the child or kept a live one. */ + * this attach spawned the child (then `starting`), kept a live one, or left the chat at rest. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null, - owner: AttachedOwner, - providerChildPhase: StructuredAgentSessionProviderChildPhase + owner: AttachedOwner ) => Promise | void + /** The waits the publish needs (the sink's drain, a provider read), outside any commit. */ + beforePublish?: (owner: AttachedOwner) => Promise + /** The commit that makes the attach's child the session's; it records the attach's outcome, which + * comes before the child is indexed. */ + onPublish?: (attached: AttachedJournal, recordOutcome: () => Promise) => Promise /** Host-owned provider sink, bound to the journal inside `onAttached`. */ eventSink?: StructuredAgentSessionEventSink /** Stops acquisition-window events targeting the superseded journal. */ @@ -138,11 +148,11 @@ export async function performAttach( let record: AgentSessionRecord let acquisitionGeneration: string | null = null let owner: AttachedOwner = 'kept' - let providerChildPhase: StructuredAgentSessionProviderChildPhase = 'ready' let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false let providerHistoryWindow: ProviderHistoryWindow | null = null + const lock = input.lock ?? heldStructuredAgentSessionAttachLock(input.acquireSignal) const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } : await prepareAdoptedTranscript(params) @@ -151,15 +161,17 @@ export async function performAttach( } try { const reserved = await withAgentSessionCreatePhase('reserve_owner', input.recordPhase, () => - store.reserveOwner( - reserveRequestFor({ - sessionId, - params, - authority: input.authority, - callerKey: input.callerKey, - fingerprint: admitted.fingerprint, - now: input.now() - }) + lock.commit(() => + store.reserveOwner( + reserveRequestFor({ + sessionId, + params, + authority: input.authority, + callerKey: input.callerKey, + fingerprint: admitted.fingerprint, + now: input.now() + }) + ) ) ) record = reserved.record @@ -196,19 +208,26 @@ export async function performAttach( // Sample provider history before a new child is acquired. Once acquireOwner // starts the child, the adapter's liveness signal intentionally becomes // conservative and an absent prompt can no longer prove non-delivery. - providerHistoryWindow = await readProviderHistoryWindow({ - adapter: input.adapter, - identity: journalIdentityFor(record, params), - accountHome: record.accountHome, - ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) - }) - if (!agentSessionLeaseAdmitsWriter(record.lease)) { + providerHistoryWindow = await lock.wait( + readProviderHistoryWindow({ + adapter: input.adapter, + identity: journalIdentityFor(record, params), + accountHome: record.accountHome, + ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) + }) + ) + if (agentSessionLeaseAdmitsWriter(record.lease)) { + // Kept: re-attached to the live child. + } else if (replayed && reserved.operationRow.outcome.status === 'succeeded') { + // A create that succeeded replays its answer; the child it started ended since, so the chat + // opens at rest and the next send starts one. + owner = 'none' + } else { const acquired = await withAgentSessionCreatePhase('acquire_owner', input.recordPhase, () => acquireOwner(input, record) ) record = acquired.record acquisitionGeneration = acquired.acquisitionGeneration - providerChildPhase = acquired.providerChildPhase owner = 'acquired' } } catch (error) { @@ -261,21 +280,30 @@ export async function performAttach( let attached: AttachedJournal try { - await input.beforeJournalOpen?.() - attached = await attachJournal({ - record, - params, - adapter: input.adapter, - openConversation: input.openConversation, - providerHistoryWindow - }) - await importAdoptedTranscript(params, attached, record, preparedTranscript.items) - await input.onAttached(attached, acquisitionGeneration, owner, providerChildPhase) - await store.recordOperationOutcome({ - callerKey: input.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'succeeded', sessionId } + const acquired = record + attached = await lock.commit(async () => { + await input.beforeJournalOpen?.() + const journal = await attachJournal({ + record: acquired, + params, + adapter: input.adapter, + openConversation: input.openConversation, + providerHistoryWindow + }) + await importAdoptedTranscript(params, journal, acquired, preparedTranscript.items) + await input.onAttached(journal, acquisitionGeneration, owner) + return journal }) + await lock.wait(input.beforePublish?.(owner) ?? Promise.resolve()) + const recordOutcome = () => + store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + await lock.commit(() => + input.onPublish ? input.onPublish(attached, recordOutcome) : recordOutcome() + ) } catch (error) { return settlePostAcquisitionAttachFailure(input, record, error) } @@ -300,32 +328,3 @@ export async function performAttach( } } } - -async function settleUnsupportedReservation( - input: AttachFlowInput, - record: AgentSessionRecord -): Promise { - const spawnToken = record.lease.reservedSpawnToken - if (!spawnToken) { - return - } - try { - await input.store.settleFailedAcquisition({ - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - spawnToken, - callerKey: input.callerKey, - operationId: input.params.envelope.clientOperationId, - outcome: { - status: 'failed', - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' }, - message: 'Structured session support changed before the provider could start.' - }, - exitProof: 'processless', - now: input.now() - }) - } catch (error) { - throw new AggregateError([error], 'agent session unsupported reservation settlement failed') - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-lock.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-lock.ts new file mode 100644 index 00000000000..c01e9a7dd33 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-lock.ts @@ -0,0 +1,18 @@ +// How an attach's steps meet the session's serialize. A client attach already holds it, so every +// step runs in place; an agent start runs outside it, races each wait with its abort signal, and +// commits each write under it after re-checking its fence (`structured-agent-session-start-flight`). + +export type StructuredAgentSessionAttachLock = { + readonly signal: AbortSignal + /** A wait on something outside Orca's state: a probe, a resolver, a provider read. */ + wait: (work: Promise) => Promise + /** A write to the session's state, which never waits on a process. */ + commit: (step: () => Promise) => Promise +} + +/** For a caller already inside the session's serialize. */ +export function heldStructuredAgentSessionAttachLock( + signal: AbortSignal = new AbortController().signal +): StructuredAgentSessionAttachLock { + return { signal, wait: (work) => work, commit: (step) => step() } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 193c764dcd0..76c1655826a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -1,4 +1,10 @@ -import { recoverStructuredRewind } from './structured-rewind-recovery' +import type { StructuredRewindRecoveryEvidence } from './structured-rewind-recovery' +import { + publishAttachedChild, + readBoundChildRewind, + type AttachCandidate, + type BoundAttach +} from './structured-agent-session-attach-publish' // The host's attach, lifted out of the host class. // // Attach is the one operation that touches every collaborator the host owns — the lease @@ -6,12 +12,13 @@ import { recoverStructuredRewind } from './structured-rewind-recovery' // queue — so leaving it inline made the host grow every time any of them did. The host keeps the // state; this owns the ordering between them. +import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' +import { refuse } from '../../../shared/agent-session-wire-refusals' import { randomUUID } from 'node:crypto' import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import type { AgentSessionAttachResult, - AgentSessionMutationResult, - AgentSessionTurnActivity + AgentSessionMutationResult } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' @@ -25,23 +32,22 @@ import { refuseAgentSessionMutation } from './structured-agent-session-mutation- import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' -import { - indexProviderChild, - structuredAgentSessionConversationFence -} from './structured-agent-session-provider-child' -import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt' -import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' +import { + isStructuredAgentSessionStartupExpired, + type StructuredAgentSessionStartupAttempt +} from './structured-agent-session-startup-attempt-contract' import { addAgentSessionCreatePhaseAttributes, withAgentSessionCreatePhase, withAgentSessionSpan, type AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' +import { + heldStructuredAgentSessionAttachLock, + type StructuredAgentSessionAttachLock +} from './structured-agent-session-attach-lock' export type StructuredAgentSessionAttachOptions = { hostLaunchDirectory?: string @@ -49,30 +55,27 @@ export type StructuredAgentSessionAttachOptions = { onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ startedFor?: string + startupOptions?: Readonly> /** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */ onAborted?: () => void /** The startup limit aborted this attach's acquire: it failed, nobody stopped it. */ onStartupExpired?: () => void + /** An agent start's: its steps run outside the session's serialize and commit under it. */ + lock?: StructuredAgentSessionAttachLock + startupAttemptId?: string + /** The attach made its child the session's. */ + onPublished?: () => void } -/** - * The attach itself, for a caller already inside the session's serialize. - * - * That is every caller that has to know what the session looks like RIGHT NOW: the delivery loop, - * and an operation that needs the provider. They run their check and this attach in one serialized - * step, so "the session has no child" is still true when - * the attach starts. `attachStructuredAgentSession` is this under `serialize`, for a client. - */ -export function attachStructuredAgentSessionUnderSerialize( +/** The attach an agent start runs under its own lock (`structured-agent-session-start-flight`); + * `attachStructuredAgentSession` runs the same steps inside one `serialize`, for a client. */ +export function attachStructuredAgentSessionForStart( context: StructuredAgentSessionAttachContext, callerKey: string, params: AgentSessionAttachParams, - options: StructuredAgentSessionAttachOptions = {} + options: StructuredAgentSessionAttachOptions & { lock: StructuredAgentSessionAttachLock } ): Promise> { - return context.tasks.trackAttach(runAttach(context, callerKey, params, options), { - sessionId: params.envelope.sessionId, - location: params.location - }) + return runAttach(context, callerKey, params, options) } export function attachStructuredAgentSession( @@ -86,9 +89,38 @@ export function attachStructuredAgentSession( // evicts, so no child is spawned behind the eviction and orphaned. const run = (recordPhase?: AgentSessionCreatePhaseRecorder) => context.tasks.trackAttach( - context.serialize(sessionId, () => - runAttach(context, callerKey, params, { ...options, recordPhase }) - ), + (async () => { + for (;;) { + const selected = await context.serialize(sessionId, async () => { + const child = context.sessions.get(sessionId)?.child + const flight = context.runtimeState.startFlights.get(sessionId) + // The owning flight alone publishes its acquisition; replay waits outside its lane. + if (!child && flight) { + return { flight } + } + const record = context.deps.store.getRecord(sessionId) + if (!child && record && agentSessionLeaseAdmitsWriter(record.lease)) { + return { + result: { + ok: false as const, + refusal: refuse( + 'agent_session_ownership_unknown', + { reason: 'noProviderChild' }, + 'The session has no provider child to write to.' + ) + } + } + } + return { + result: await runAttach(context, callerKey, params, { ...options, recordPhase }) + } + }) + if (selected.result !== undefined) { + return selected.result + } + await selected.flight.outcome + } + })(), { sessionId, location: params.location } ) if (params.envelope.expectedRuntimeFence !== null) { @@ -114,15 +146,19 @@ async function runAttach( params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions ): Promise> { - // Begun first, so a close or Stop during any phase below still stops the start. - const acquire = context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + // Begun first, so a close or Stop during any phase below still stops the start. A start's lock + // carries the controller it registered before its first wait. + const acquire = options.lock + ? null + : context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + const lock = options.lock ?? heldStructuredAgentSessionAttachLock(acquire?.signal) try { - return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) + return await runAttachUnderAbort(context, callerKey, params, options, lock) } finally { - acquire.end() - if (isStructuredAgentSessionStartupExpired(acquire.signal.reason)) { + acquire?.end() + if (isStructuredAgentSessionStartupExpired(lock.signal.reason)) { options.onStartupExpired?.() - } else if (acquire.signal.aborted) { + } else if (lock.signal.aborted) { options.onAborted?.() } } @@ -133,7 +169,7 @@ async function runAttachUnderAbort( callerKey: string, params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions, - acquireSignal: AbortSignal + lock: StructuredAgentSessionAttachLock ): Promise> { const sessionId = params.envelope.sessionId const recordPhase = options.recordPhase @@ -141,17 +177,18 @@ async function runAttachUnderAbort( const fenceBefore = context.sessions.has(sessionId) ? structuredAgentSessionConversationFence(context.deps.store, sessionId) : null + // Each verdict commits in the store's own transaction, against the owner identity it probed. const unreconciled = await withAgentSessionCreatePhase('reconcile_leases', recordPhase, () => - context.reconcileLeases(sessionId) + lock.wait(context.reconcileLeases(sessionId)) ) if (unreconciled) { return refuseAgentSessionMutation(unreconciled) } await withAgentSessionCreatePhase('resolve_recovery', recordPhase, () => - context.runtimeState.resolveRecovery(sessionId) + lock.wait(context.runtimeState.resolveRecovery(sessionId)) ) const probe = await withAgentSessionCreatePhase('probe_owner', recordPhase, () => - context.runtimeState.probeOwner(sessionId) + lock.wait(context.runtimeState.probeOwner(sessionId)) ) // Read before the reserve clears it: how the previous generation ended decides how whatever it // left running is settled. @@ -160,7 +197,9 @@ async function runAttachUnderAbort( const launchDirectory = !priorRecord && isFloatingWorkspaceId(params.location.workspaceId) ? (options.hostLaunchDirectory ?? - (await context.deps.resolveWorkspacePath?.(params.location.workspaceId))) + (await lock.wait( + Promise.resolve(context.deps.resolveWorkspacePath?.(params.location.workspaceId)) + ))) : undefined if (!priorRecord && isFloatingWorkspaceId(params.location.workspaceId) && !launchDirectory) { throw new Error('floating_agent_session_launch_directory_unavailable') @@ -173,7 +212,9 @@ async function runAttachUnderAbort( candidate: AttachCandidate | null committed: boolean startup: StructuredAgentSessionStartupAttempt | null - } = { candidate: null, committed: false, startup: null } + bound: BoundAttach | null + rewind: StructuredRewindRecoveryEvidence + } = { candidate: null, committed: false, startup: null, bound: null, rewind: null } const { startupAttempts } = context.runtimeState try { const attached = await performAttach({ @@ -184,7 +225,9 @@ async function runAttachUnderAbort( eventSink: attemptSink.sink, // The superseded child's writes settle into its own journal before a new child starts. onAcquiring: async () => { - const barrier = await context.runtimeState.currentEventSink(sessionId)?.drained() + const barrier = await lock.wait( + Promise.resolve(context.runtimeState.currentEventSink(sessionId)?.drained()) + ) if (barrier && !barrier.ok) { throw barrier.error } @@ -195,22 +238,24 @@ async function runAttachUnderAbort( claimKeyId: context.deps.claimKeyId, handoffOperationId: params.envelope.clientOperationId, probe, - ...(await pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params)), - ...(await pinnedAgentSessionLaunchEnv(context.deps.resolveLaunchEnv, params)) + ...(await lock.wait(pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params))), + ...(await lock.wait(pinnedAgentSessionLaunchEnv(context.deps.resolveLaunchEnv, params))) }, callerKey, params, now: () => context.now(), recordPhase, - acquireSignal, - onStartCatalogListing: (listing) => - context.deps.modelCatalog?.recordLiveListing(sessionId, listing), + acquireSignal: lock.signal, + lock, + ...(options.startupAttemptId ? { startupAttemptId: options.startupAttemptId } : {}), onStartupAttempt: (startup) => { attempt.startup = startup return startupAttempts.track(sessionId, startup) }, + ...(options.startupOptions ? { startupOptions: options.startupOptions } : {}), launchPermissionMode: async (record) => - (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, + (await lock.wait(structuredAgentSessionLaunchPermission(context.deps, record))) ?? + undefined, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { acquisition: true }) @@ -222,13 +267,10 @@ async function runAttachUnderAbort( // The cleanup released the acquisition, which for a re-attach is the live child itself. onAcquisitionReleased: (cause, verdict) => endStructuredAgentSessionReleasedChild(context, sessionId, cause, verdict), - onAttached: async (attached, acquisitionGeneration, owner, providerChildPhase) => { + // The commit before the drain: stale rows settle, and the sink binds to the journal. + onAttached: async (attached, acquisitionGeneration, owner) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null - const startedFor = owner === 'acquired' ? options.startedFor : current?.startedFor - // A re-attach to a live child keeps the mode that child was launched with. - const permissionMode = - owner === 'acquired' ? attempt.startup?.permissionMode : current?.permissionMode // A re-attach to a live child keeps the sink that child already writes through. const eventSink = owner === 'acquired' @@ -246,55 +288,39 @@ async function runAttachUnderAbort( failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord) }) } - await bindAndDrain(eventSink, attached.journal, fence, (activity) => - context.subscribers.publish(sessionId, attached.journal, activity) - ) - attempt.candidate = { - sink: eventSink, - child: { - generation: acquisitionGeneration ?? current?.generation ?? null, - fence, - // An acquire reports its phase; a re-attach keeps what its child already proved. - phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), - ...(startedFor === undefined ? {} : { startedFor }), - ...(permissionMode === undefined ? {} : { permissionMode }), - heldSends: () => context.deps.adapter.heldSends?.(sessionId) - } + eventSink.bind({ + journal: attached.journal, + fence, + publish: (activity) => context.subscribers.publish(sessionId, attached.journal, activity) + }) + const { journal } = attached + attempt.bound = { acquisitionGeneration, owner, fence, journal, current, eventSink } + }, + // Outside the commits: the barrier the host publishes behind, and for a proven child the + // provider's word on a rewind in doubt (a starting one recovers in its open step). + beforePublish: async (owner) => { + const barrier = await attempt.bound?.eventSink.drained() + if (barrier && !barrier.ok) { + throw barrier.error } - // Only a proven child has the protocol session recovery asks; a starting one recovers in - // its `started` step, before it is handed anything. - if (attempt.candidate?.child.phase === 'ready') { - await recoverStructuredRewind( - context.deps, - sessionId, - attached.journal, - fence, - context.deps.adapter - ) + if (owner === 'kept' && attempt.bound?.current?.phase === 'ready') { + attempt.rewind = await readBoundChildRewind(context, sessionId, attempt.bound) } - if (fenceBefore !== null && fence !== fenceBefore) { - context.subscribers.snapshot(sessionId, attached.journal, fence) - } else { - context.subscribers.publish(sessionId, attached.journal) + }, + onPublish: async (attached, recordOutcome) => { + const published = await publishAttachedChild(context, sessionId, attached, { + bound: attempt.bound, + attempt, + attemptSink, + fenceBefore, + startedFor: options.startedFor, + recordOutcome + }) + if (published) { + options.onPublished?.() } } }) - const { candidate } = attempt - const conversation = context.sessions.get(sessionId) - if (attached.ok && candidate && conversation) { - context.runtimeState.adoptEventSink(sessionId, candidate.sink) - attempt.committed = candidate.sink === attemptSink - indexProviderChild(conversation, candidate.child) - if (attempt.startup) { - startupAttempts.published(sessionId, attempt.startup.attemptId, candidate.child) - if (candidate.child.phase === 'ready') { - noteStructuredAgentSessionProviderStarted(context.deps, sessionId) - } else { - context.openProviderChild?.(sessionId, candidate.child) - } - } - context.publishStatus?.(sessionId) - } return stampFailedCreateOwnerVerdict(context.deps.store, callerKey, params.envelope, attached) } finally { if (!attempt.committed) { @@ -305,23 +331,3 @@ async function runAttachUnderAbort( } } } - -type AttachCandidate = { - child: StructuredAgentSessionProviderChild - sink: DeferredStructuredAgentSessionEventSink -} - -/** Binds the sink to the journal and waits for the barrier the host publishes - * behind. It throws by design when a sink barrier fails. */ -async function bindAndDrain( - eventSink: DeferredStructuredAgentSessionEventSink, - journal: AgentSessionJournal, - fence: number, - publish: (activity?: AgentSessionTurnActivity | null) => void -): Promise { - eventSink.bind({ journal, fence, publish }) - const barrier = await eventSink.drained() - if (!barrier.ok) { - throw barrier.error - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts new file mode 100644 index 00000000000..d11c27786c2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts @@ -0,0 +1,138 @@ +// The attach's last commit: the child it spawned or kept becomes the session's, its sink the one +// the session's events go through, and its open begins. + +import type { AttachedJournal } from './structured-agent-session-attach' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AttachedOwner } from './structured-agent-session-attach-flow' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' +import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { indexProviderChild } from './structured-agent-session-provider-child' +import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' +import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' +import { structuredAgentSessionEndedEventExit } from './structured-agent-session-child-exit' +import { + applyStructuredRewindRecovery, + readStructuredRewindRecovery, + type StructuredRewindRecoveryEvidence +} from './structured-rewind-recovery' + +export type AttachCandidate = { + child: StructuredAgentSessionProviderChild + sink: DeferredStructuredAgentSessionEventSink +} + +export type BoundAttach = { + acquisitionGeneration: string | null + owner: AttachedOwner + fence: number + journal: AgentSessionJournal + current: StructuredAgentSessionProviderChild | null + eventSink: DeferredStructuredAgentSessionEventSink +} + +/** The provider's word on a rewind in doubt for the child this attach bound, read outside the + * commits so no send waits behind it. */ +export function readBoundChildRewind( + context: StructuredAgentSessionAttachContext, + sessionId: string, + bound: BoundAttach +): Promise { + return readStructuredRewindRecovery( + context.deps, + sessionId, + bound.journal, + bound.fence, + context.deps.adapter + ) +} + +/** The attach's last commit: its child, if it has one, becomes the session's. */ +export async function publishAttachedChild( + context: StructuredAgentSessionAttachContext, + sessionId: string, + attached: AttachedJournal, + input: { + bound: BoundAttach | null + attempt: { + candidate: AttachCandidate | null + committed: boolean + startup: StructuredAgentSessionStartupAttempt | null + rewind: StructuredRewindRecoveryEvidence + } + attemptSink: DeferredStructuredAgentSessionEventSink + fenceBefore: number | null + startedFor: string | undefined + recordOutcome: () => Promise + } +): Promise { + const { attempt, bound } = input + if (!bound) { + await input.recordOutcome() + return false + } + const { acquisitionGeneration, owner, fence, current, eventSink } = bound + // A re-attach to a live child keeps the mode that child was launched with. + const permissionMode = + owner === 'acquired' ? attempt.startup?.permissionMode : current?.permissionMode + const startedFor = owner === 'acquired' ? input.startedFor : current?.startedFor + // A replayed create over a chat at rest has no child to index. + const child = + owner === 'acquired' + ? { + generation: acquisitionGeneration, + fence, + phase: 'starting' as const, + heldSends: () => context.deps.adapter.heldSends?.(sessionId), + ...(startedFor === undefined ? {} : { startedFor }), + ...(permissionMode === undefined ? {} : { permissionMode }) + } + : owner === 'kept' && current + ? current + : null + if (child) { + attempt.candidate = { sink: eventSink, child } + } + if (attempt.candidate?.child.phase === 'ready') { + await applyStructuredRewindRecovery( + context.deps, + sessionId, + attached.journal, + fence, + attempt.rewind + ) + } + if (input.fenceBefore !== null && fence !== input.fenceBefore) { + context.subscribers.snapshot(sessionId, attached.journal, fence) + } else { + context.subscribers.publish(sessionId, attached.journal) + } + await input.recordOutcome() + const { candidate } = attempt + const conversation = context.sessions.get(sessionId) + if (!candidate || !conversation) { + return false + } + context.runtimeState.adoptEventSink(sessionId, candidate.sink) + attempt.committed = candidate.sink === input.attemptSink + indexProviderChild(conversation, candidate.child) + // An exit that landed while the start ran outside the serialize ends the child it now indexed. + const exited = context.runtimeState.startFlights.takeExit(sessionId, candidate.child) + const indexed = conversation.child + if (exited && indexed) { + await context.endExitedChild(sessionId, indexed, structuredAgentSessionEndedEventExit(exited)) + } else if (attempt.startup) { + context.runtimeState.startupAttempts.published( + sessionId, + attempt.startup.attemptId, + candidate.child + ) + if (candidate.child.phase === 'ready') { + noteStructuredAgentSessionProviderStarted(context.deps, sessionId) + } else { + context.openProviderChild?.(sessionId, candidate.child) + } + } + context.publishStatus?.(sessionId) + return true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts index e76e2794ee2..b5aa9f79adc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts @@ -45,6 +45,7 @@ export function captureChatStopTarget( child: StructuredAgentSessionProviderChild | null turnId?: string endsSession: boolean + stoppedPrePrompt?: boolean } ): ChatStopTarget | null { const { child, turnId } = input @@ -72,7 +73,7 @@ export function captureChatStopTarget( // A Stop naming no turn ends nothing more unless the session reads working, by the rule every // session list and the chat's own Stop read it, over the fold as it stands. if (!child || (turnId === undefined && !isMainAgentWorking(ctx))) { - return queued ? { ...target, reach: 'withdraw', marks: true } : null + return queued || input.stoppedPrePrompt ? { ...target, reach: 'withdraw', marks: true } : null } // Repeating the Stop in force with nothing sent since writes no second event, so a card queued // between the presses sends normally, as after one Stop. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index b2f84180868..f69677845e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -27,7 +27,11 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { acceptChatStop, acceptStopTarget } from './structured-agent-session-stop-acceptance' -import { noteStructuredAgentSessionStopUnconfirmed } from './structured-agent-session-stop-unconfirmed-note' +import { + noteStructuredAgentSessionStopUnconfirmed, + writeStructuredAgentSessionStopNote +} from './structured-agent-session-stop-unconfirmed-note' +import { STOP_NOTE_CANCELLATION_REQUESTED } from './structured-agent-session-turn-stop-notes' import { endStoppedStructuredAgentSession, type StructuredAgentSessionStopWindDown @@ -45,7 +49,7 @@ export type StructuredAgentSessionChatStopRun = { outcome: ChatStopOutcome; ends export function mutateWithChatStop( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: { envelope: AgentSessionMutationEnvelope; turnId?: string }, + params: { envelope: AgentSessionMutationEnvelope; turnId?: string; stoppedPrePrompt?: boolean }, plan: MutationPlan, run: ( ctx: AgentSessionTurnContext, @@ -58,6 +62,7 @@ export function mutateWithChatStop( let windDown: | { owed: StructuredAgentSessionStopWindDown; ctx: AgentSessionTurnContext } | undefined + let reached = params.stoppedPrePrompt === true const named = turnId !== undefined ? { turnId } : {} // Its own step wrote the Stop's event first. const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) @@ -85,7 +90,13 @@ export function mutateWithChatStop( fence: ctx.fence }) return accepted.ok - ? { ok: true, value: { settled: target.queued, turnId: accepted.value.turnId } } + ? { + ok: true, + value: { + settled: target.queued || reached, + turnId: accepted.value.turnId + } + } : accepted } /** After the acceptance commits: only while what it captured still stands. */ @@ -134,10 +145,13 @@ export function mutateWithChatStop( ) } const stop = async (ctx: AgentSessionTurnContext): Promise => { + const operations = context.acquireAborts.captureOperations(sessionId) + const flight = context.startFlights.get(sessionId) const target = captureChatStopTarget(ctx, { child: context.sessions.get(ctx.sessionId)?.child ?? null, ...named, - endsSession: ctx.adapter.stopEndsSession?.(ctx.sessionId) === true + endsSession: ctx.adapter.stopEndsSession?.(ctx.sessionId) === true, + stoppedPrePrompt: reached || operations.length > 0 || flight !== undefined }) if (!target) { // Late, or nothing to stop: answered as a no-op, its receipt committed before the answer. @@ -147,8 +161,24 @@ export function mutateWithChatStop( if (!accepted.ok) { return accepted } + for (const abort of operations) { + abort() + reached = true + } + // Invalidation belongs to the accepted Stop, including a claim made after its early abort. + if (flight && (target.queued || target.reach === 'withdraw' || target.reach === 'starting')) { + flight.abort() + } + if (reached && target.reach === 'withdraw') { + await writeStructuredAgentSessionStopNote( + ctx, + accepted.value.turnId ?? envelope.clientOperationId, + { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, + contextStructuredAgentSessionCurrentWork(ctx).turnScope() + ) + } try { - return await act(ctx, target, accepted.value.settled) + return await act(ctx, target, accepted.value.settled || reached || flight !== undefined) } catch (error) { // Saved, then failed: noted on the chat, then thrown so the answer is the Stop's receipt. // On the turn its event named, else the one it named, else the one live now. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index c799ba1a161..93007994de7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -14,6 +14,7 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-ses import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import { releaseStoredStructuredAgentSessionOwnerAfterExit } from './structured-agent-session-lease-release' import { isSurfaceReleasableAgentSessionRecord } from '../../runtime/agent-session-surface-release-transition' +import type { StructuredAgentSessionAttachLock } from './structured-agent-session-attach-lock' /** What a caller learned about the child's exit: proven, or not. A root still there after the * close's kill reads `unverifiable`, never `exited`. */ @@ -30,6 +31,16 @@ export async function joinStructuredAgentSessionChildClose( if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } + await endClosedStructuredAgentSessionChild(context, sessionId, child, unanswered) + return 'exited' +} + +async function endClosedStructuredAgentSessionChild( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + child: StructuredAgentSessionProviderChild, + unanswered: boolean +): Promise { await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca', @@ -37,7 +48,6 @@ export async function joinStructuredAgentSessionChildClose( ...(unanswered ? { startupUnanswered: true } : {}) }) context.restartWitness?.stopped(sessionId) - return 'exited' } /** The refusal of an operation that met a child whose close is still unproven. */ @@ -51,16 +61,28 @@ export function previousExitUnverifiableRefusal(): AgentSessionWireRefusal { /** For an operation that reaches the provider: a child a stop began closing takes no input and * none may start beside it, so the operation joins that close and is refused while it is still - * unproven. */ + * unproven. A start passes its own lock: the close is awaited outside the session's serialize and + * the record ends in a commit, only if the child is still the one closed. */ export async function joinClosingStructuredAgentSessionChild( context: StructuredAgentSessionLifetimeContext, - sessionId: string + sessionId: string, + lock?: StructuredAgentSessionAttachLock ): Promise<{ ok: true } | { ok: false; refusal: AgentSessionWireRefusal }> { const child = context.sessions.get(sessionId)?.child if (!child?.close) { return { ok: true } } - if ((await joinStructuredAgentSessionChildClose(context, sessionId, child)) === 'exited') { + const unanswered = child.phase === 'starting' + const closing = closeProviderRoot(context, sessionId) + if (await (lock ? lock.wait(closing) : closing)) { + const end = () => endClosedStructuredAgentSessionChild(context, sessionId, child, unanswered) + await (lock + ? lock.commit(async () => { + if (context.sessions.get(sessionId)?.child === child) { + await end() + } + }) + : end()) return { ok: true } } // A stop reports this through its own failure; here the refusal is the only trace. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 60dda17a157..6c922fb24fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -35,6 +35,7 @@ import { import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { evictStructuredAgentSession } from './structured-agent-session-eviction' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' /** How the child's root went: the close this host asked for, or a death of its own. */ @@ -45,7 +46,6 @@ export type StructuredAgentSessionChildExit = { failure?: SubmissionRejectionFact /** Host receipt of the exit: the end time of a turn it interrupted. */ observedAt?: number - startupUnproven?: true startupUnanswered?: true } @@ -75,6 +75,8 @@ export type StructuredAgentSessionChildExitContext< wakeDelivery?: (sessionId: string) => void /** An ended child's start, if still open, has nothing left to time. */ startupAttempts?: Pick + /** The start in flight outside the serialize, whose child is not on record until it publishes. */ + startFlights?: Pick abortOpen?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number @@ -102,20 +104,35 @@ export function settleStructuredAgentSessionChildExit< ): Promise { return context.serialize(event.sessionId, async () => { const child = context.sessions.get(event.sessionId)?.child + // An exit can land between a start's spawn and its publish: the publish settles it. + if (!child && context.startFlights?.deferExit(event)) { + return + } if (!child || child.fence !== event.fence || child.generation !== event.acquisitionGeneration) { return } - await endExitedStructuredAgentSessionChildUnderSerialize(context, event.sessionId, child, { - expected: event.cause === 'requested-close', - reason: event.reason, - ...(event.failure ? { failure: event.failure } : {}), - ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), - ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) - }) + await endExitedStructuredAgentSessionChildUnderSerialize( + context, + event.sessionId, + child, + structuredAgentSessionEndedEventExit(event) + ) }) } +/** The exit an adapter's report of it proves. */ +export function structuredAgentSessionEndedEventExit( + event: StructuredAgentSessionEndedEvent +): StructuredAgentSessionChildExit { + return { + expected: event.cause === 'requested-close', + reason: event.reason, + ...(event.failure ? { failure: event.failure } : {}), + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), + ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) + } +} + /** Ends the record of a child whose exit this host saw or proved; a no-op once it has left. */ export async function endExitedStructuredAgentSessionChildUnderSerialize< TSession extends StructuredAgentSessionChildExitSession @@ -135,7 +152,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< const observedAt = exit.observedAt ?? context.now() // The host's own phase decides, so a provider that omits the flag still gets a start that // failed told as one: the row says so. - const exitedDuringStartup = exit.startupUnproven === true || child.phase === 'starting' + const exitedDuringStartup = child.phase === 'starting' if (child.phase === 'starting') { context.abortOpen?.(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts index 144ab38a7d7..93b4f5bc856 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts @@ -4,12 +4,17 @@ import { describe, expect, it } from 'vitest' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { attach, hostTestState, serveHostTestChildWork } from './structured-agent-session-host-test-harness' -import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' function running(id: string, over: Partial = {}): AgentChildWorkView { return { @@ -83,3 +88,37 @@ describe("the host's strip channel and summary list the same running children", expect(await strip()).toBeNull() }) }) + +describe('a session list reads the main agent apart from its subagents', () => { + it('as done once its turn ends while only a subagent still runs', async () => { + let records: AgentChildWorkView[] = [] + serveHostTestChildWork(() => records) + await attach() + const { host, acquire } = hostTestState() + const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + for (const state of ['running', 'completed'] as const) { + acquire.mock.calls + .at(-1)?.[0] + .events?.appendItem( + turn, + { kind: 'turn', turnId: 'turn-1', state }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + records = [running('subagent')] + host.publishChildWorkEvidence(SESSION, []) + await host.flushStreamedEvents(SESSION) + + const summary = host.readStatusSummary(SESSION)! + const row = structuredAgentSessionAgentStatus({ + ...summary, + status: summary.status!, + childWork: summary.children + }) + // Working for the subagent; the main agent, which the chat's Stop follows, is done. + expect({ mainAgent: row.mainAgent.state, row: row.state }).toEqual({ + mainAgent: 'done', + row: 'working' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts deleted file mode 100644 index 6b50d0bc03f..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-claude' } -const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' -const DEFAULT_MODEL = 'sonnet' -const PICKED_MODEL = 'opus' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let activeModel: string -let activeEffort: string | null -let dispatch: Mock -let order: string[] -let setOption: Mock -let releaseOpen: () => void - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function adapter(): StructuredAgentSessionAdapter { - setOption = vi.fn(async ({ key, value }) => { - order.push(`${key}=${value}`) - if (key === 'model') { - activeModel = value - } else if (key === 'effort') { - activeEffort = value - } - return { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } - }) - dispatch = vi.fn(async () => { - order.push('dispatch') - return { state: 'admitted' as const } - }) - acquire = vi.fn(async ({ fence, spawnToken, options }) => { - activeModel = options?.model ?? DEFAULT_MODEL - return { - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `claude-native-${fence}`, - handle: claudeProviderHandle(CLAUDE_SESSION, 'native-leaf'), - origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - return { - open: () => - new Promise((resolve) => { - releaseOpen = () => - resolve({ - reportedOptions: { model: DEFAULT_MODEL } - }) - }), - acquire, - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption, - readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })), - closeSession: vi.fn(async () => { - activeModel = DEFAULT_MODEL - return true - }) - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-claude-options-')) - resetHostTestOperationIds() - activeModel = DEFAULT_MODEL - activeEffort = null - order = [] - releaseOpen = () => {} - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-claude', - now: () => NOW - }) - expect( - await host.attach( - CALLER, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'native-leaf' } - }) - ) - ).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)) - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) -}) - -it('applies model and effort picks on a running Claude chat to the next message', async () => { - releaseOpen() - await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) - for (const [key, value] of [ - ['model', PICKED_MODEL], - ['effort', 'high'] - ]) { - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key, value }), - key, - value - }) - ).toMatchObject({ ok: true }) - } - const body = hostTestMessage('with the new choices') - dispatch.mockImplementationOnce(async () => { - expect(activeModel).toBe(PICKED_MODEL) - expect(setOption).toHaveBeenCalledWith( - expect.objectContaining({ key: 'effort', value: 'high' }) - ) - return { state: 'admitted' as const } - }) - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual(['model=opus', 'effort=high']) -}) - -it('saves a waiting startup pick when Stop ends the child before it is ready', async () => { - const fields = { key: 'model', value: PICKED_MODEL } - const picked = host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - try { - expect( - await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) - ).toMatchObject({ ok: true }) - expect(await picked).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options?.model).toBe(PICKED_MODEL) - expect(setOption).not.toHaveBeenCalled() - } finally { - releaseOpen() - } -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 35763ea56a3..7fabb3bff8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -112,7 +112,7 @@ describe('Claude root-exit stop', () => { journal, params, child: { - generation: acquisition.acquisitionGeneration ?? null, + generation: acquisition.acquisitionGeneration, fence, phase: 'ready' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index 14adfb0f2b6..454bcf0bf89 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -453,13 +453,15 @@ it('rejects a message whose start meets a close still unverifiable, and starts n it('saves an option change while the close stays unverifiable without writing to the old child', async () => { const connection = await stopWithUnprovenClose(2) + const calls = [...connection.calls] await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) expect(connection.closeCount).toBe(1) - expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) + expect(connection.calls).toEqual(calls) // The pick remains saved for a later start while the old child is still unverifiable. await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(connection.calls).toEqual(calls) expect(child()?.close).toBeDefined() expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts new file mode 100644 index 00000000000..01097dd4850 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function publishRunningTurn(first: string): Promise { + rig.providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 99 }, + { + kind: 'turn', + turnId: 'turn-a', + state: 'running', + userItemId: agentJournalSubmissionKey(first) + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) +} + +it.each([ + { label: 'pause', change: { kind: 'status', status: 'paused' } }, + { label: 'edit', change: { kind: 'set', objective: 'Updated objective' } }, + { label: 'clear', change: { kind: 'clear' } } +] as const)( + 'runs a goal $label while Codex holds an earlier send for different choices', + async ({ change }) => { + const changeGoal = vi.fn(async () => ({ ok: true as const })) + rig.host.deps.adapter.changeThreadGoal = changeGoal + const fields = { key: 'model', value: 'model-a' } + expect( + await rig.host.setOption(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true }) + const first = await rig.workingSend() + await rig.settleAccepted(first, 'a') + await publishRunningTurn(first) + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.newestTurn() + ).toMatchObject({ state: 'running', userItemId: agentJournalSubmissionKey(first) }) + const held = rig.send('Use a different model', undefined, { + carried: { model: 'model-b' } + }) + expect(await held.result).toMatchObject({ ok: true }) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) + expect(rig.dispatch).toHaveBeenCalledOnce() + const goal = rig.host.changeThreadGoal(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ change }, 'agentSession.threadGoal', hostTestOperationId()), + change + }) + await eventually(() => expect(changeGoal).toHaveBeenCalledOnce()) + expect(await goal).toMatchObject({ ok: true }) + expect(changeGoal).toHaveBeenCalledWith(expect.objectContaining({ change })) + expect(rig.dispatch).toHaveBeenCalledOnce() + expect(await rig.submission(held.id)).toMatchObject({ dispatchState: 'pending' }) + expect((await rig.submission(held.id))?.handedOverAt).toBeUndefined() + } +) + +it('releases a startup goal when a second message parks while the first turn opens', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + const changeGoal = vi.fn(async () => ({ ok: true as const })) + rig.host.deps.adapter.changeThreadGoal = changeGoal + const first = rig.send('First model', undefined, { carried: { model: 'model-a' } }) + const second = rig.send('Second model', undefined, { carried: { model: 'model-b' } }) + await first.result + await second.result + const change = { kind: 'set' as const, objective: 'A startup objective' } + const goal = rig.host.changeThreadGoal(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ change }, 'agentSession.threadGoal', hostTestOperationId()), + change + }) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) + await rig.proveStart() + await eventually(() => expect(changeGoal).toHaveBeenCalledOnce()) + expect(await goal).toMatchObject({ ok: true }) + expect(rig.dispatch).toHaveBeenCalledOnce() + await rig.settleAccepted(first.id, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts index 3ee2b83a412..c75e460bf6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts @@ -70,7 +70,7 @@ beforeEach(async () => { adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event) { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { void host.handleAdapterEvent(event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index f6d7d6d4be7..4a49b445b4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -65,7 +65,7 @@ beforeEach(async () => { const adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event) { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { void host.handleAdapterEvent(event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts deleted file mode 100644 index 53dcb9d420f..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -// Stopping an agent and closing its conversation's handle are two things, and a reader, a session -// list and a send each see only the one that happened. Ticks are driven by hand. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { setStructuredAgentSessionHost } from './structured-agent-session-registry' -import { - collectSubscriber, - createRestTestRig, - foundRestTestChat, - IDLE_MS, - readerSaw, - REST_TEST_CALLER as CALLER, - REST_TEST_SESSION as SESSION, - restTestSend, - sweepOnce, - type RestTestRig -} from './structured-agent-session-rest-test-rig' -import { hostTestAttachParams } from './structured-agent-session-host-test-data' - -let rig: RestTestRig - -const COLD_START = { timeout: 10_000 } - -beforeEach(async () => { - rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) - setStructuredAgentSessionHost(rig.host) -}) - -afterEach(async () => { - setStructuredAgentSessionHost(null) - await rig.dispose() -}) - -function fence(): number { - return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 -} - -function openSession(): StructuredAgentSessionHostSession | undefined { - return rig.host.collaboratorsForTests().sessions.get(SESSION) -} - -function statusRows(): AgentSessionStatusSummary[] { - return rig.statusEvents - .flatMap((event) => (event.type === 'status' ? [event.session] : [])) - .filter((summary) => summary.sessionId === SESSION) -} - -describe('a stop that fails', () => { - it('is retried on the next tick (P2-11 a)', async () => { - await foundRestTestChat(rig) - rig.adapter.closeSession.mockResolvedValueOnce(false) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - // The child stays on record with its close unproven, and the next tick joins it. - expect(openSession()?.child?.close).toMatchObject({ cause: 'evict' }) - await sweepOnce(rig.host) - - expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() - }) - - it('re-derives a release its wind-down could not write before the handle closes (P2-11 b)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - // The child is proven gone and its work settled, then handing the lease back fails once. - const transition = vi - .spyOn(rig.store, 'transitionHandoff') - .mockRejectedValueOnce(new Error('store unavailable')) - - await sweepOnce(rig.host) - - // Reported, never retried as a stop: the handle's close writes the release from the proof. - expect(transition).toHaveBeenCalledTimes(2) - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(rig.adapter.closeSession).toHaveBeenCalledOnce() - expect(rig.host.hasSession(SESSION)).toBe(false) - }) -}) - -describe('closing the handle', () => { - it('keeps a tabbed chat listed and its reader open, and a send reopens it for that reader (P2-14)', async () => { - await foundRestTestChat(rig) - const reader = collectSubscriber() - await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - // The row stays in the agent-status store, idle and no longer running here, and a session - // list that connects now still gets it. - expect(rig.sink.forget).not.toHaveBeenCalled() - expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) - expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() - const late: { type: string; sessions?: AgentSessionStatusSummary[] }[] = [] - rig.host.subscribeStatus({ id: 'late', emit: (event) => late.push(event) }) - expect(late[0]?.sessions?.map((summary) => summary.sessionId)).toContain(SESSION) - expect(reader.events.some((event) => event.type === 'end')).toBe(false) - - const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) - expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) - // The reader opened on the first handle is fed by the second. - await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('after the close')) - }) - - it('drops the agent-status row of a chat whose tab is gone (P2-14, retired)', async () => { - await foundRestTestChat(rig) - await rig.store.setSessionTabVisibility(SESSION, false) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - expect(rig.sink.forget).toHaveBeenCalled() - }) - - it('forgets the row of a resting chat once its tab closes (P2-32)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - await sweepOnce(rig.host) - expect(rig.sink.forget).not.toHaveBeenCalled() - - await rig.host.setSessionTabVisibility(SESSION, false) - expect(rig.sink.forget).toHaveBeenCalled() - }) - - it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { - await foundRestTestChat(rig) - - await rig.host.close(SESSION, 'evict') - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - // The stop says not-running; the row belongs to the tab, so nothing forgets it. - expect(rig.sink.forget).not.toHaveBeenCalled() - expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) - expect(rig.sink.publish.mock.calls.at(-1)?.[0].hostExecutionOwned).toBeUndefined() - - await rig.host.setSessionTabVisibility(SESSION, false) - expect(rig.sink.forget).toHaveBeenCalledOnce() - }) - - it('never hands a reader a handle it is closing (P2-25)', async () => { - await foundRestTestChat(rig) - const journal = openSession()?.journal - if (!journal) { - throw new Error('the conversation should be open') - } - const closing = Promise.withResolvers() - const gate = Promise.withResolvers() - const close = journal.close.bind(journal) - vi.spyOn(journal, 'close').mockImplementation(async () => { - await close() - closing.resolve() - await gate.promise - }) - rig.clock.now += IDLE_MS + 1 - const tick = sweepOnce(rig.host) - await closing.promise - - const history = rig.host.history({ sessionId: SESSION, direction: 'tail' }) - const facts = readStructuredSessionGateFacts(SESSION) - gate.resolve() - await tick - - const page = await history - expect(page.ok && page.page.items.length).toBeGreaterThan(0) - await expect(facts).resolves.not.toBeNull() - // And the handle a write finds is a live one. - const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) - expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) - }) - - it('never re-enters the session lock: a send and a Stop right after still finish (P2-28)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - const tick = sweepOnce(rig.host) - const within = (work: Promise) => - Promise.race([ - work, - new Promise((_, reject) => setTimeout(() => reject(new Error('wedged')), 2_000)) - ]) - const sent = within(rig.host.send(CALLER, restTestSend('right after', fence()))) - - await within(tick) - expect(await sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - await expect(within(sweepOnce(rig.host))).resolves.toBeUndefined() - }) -}) - -describe('the sweep and the lease (P2-20)', () => { - it('reads only open conversations and never probes or resolves a lease', async () => { - await foundRestTestChat(rig) - const probeOwner = vi.fn(async () => ({ outcome: 'pid-absent' as const })) - await rig.restart({ probeOwner }) - setStructuredAgentSessionHost(rig.host) - await rig.store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'recovering' } - })) - const resolveRecovery = vi.spyOn( - rig.host.collaboratorsForTests().runtimeState, - 'resolveRecovery' - ) - // Open, at rest, on a lease still recovering. - await rig.host.journalSnapshot(SESSION) - expect(rig.host.hasSession(SESSION)).toBe(true) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - expect(probeOwner).not.toHaveBeenCalled() - expect(resolveRecovery).not.toHaveBeenCalled() - }) -}) - -describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the message held for it carries its one row and rejection', async () => { - const stopReason = 'Codex never finished starting, so Orca stopped it.' - const spawn = rig.adapter.acquire.getMockImplementation()! - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(spawn)) - // Held for the starting child, which never proves its start. - const reader = collectSubscriber() - const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) - expect(attached.ok).toBe(true) - await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) - expect(sent.ok).toBe(true) - await vi.waitFor(() => - expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') - ) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - await vi.waitFor(() => - expect(readerSaw(reader.events).submissions).toContainEqual( - expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) - ) - ) - const errorRows = reader.events.flatMap((event) => - event.type === 'batch' - ? event.batch.items.filter( - (item) => item.body.kind === 'status' && item.body.tone === 'error' - ) - : [] - ) - expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ - stopReason - ]) - - const again = await rig.host.send(CALLER, restTestSend('try again', fence())) - expect(again.ok).toBe(true) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(1), COLD_START) - await expect(sweepOnce(rig.host)).resolves.toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index da63a25dee2..d9bab20d37c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -29,7 +29,10 @@ import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation- import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' import { sameProviderChild } from './structured-agent-session-provider-child' import type { ExecutionHostId } from '../../../shared/execution-host' -import { stopWorkspaceSleepChat } from './structured-agent-session-workspace-sleep-stop' +import { + stopWorkspaceSleepChat, + workspaceSleepStartAborts +} from './structured-agent-session-workspace-sleep-stop' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' import { providerStartupFailureFact, @@ -69,7 +72,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { return false } - const closed = await closeStructuredAgentSessionConversationUnderSerialize( + return closeStructuredAgentSessionConversationUnderSerialize( { sessions, closeStatus: (id) => { @@ -81,7 +84,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId, atRest ) - return closed } const hasOpenDispatch = (sessionId: string): boolean => { @@ -98,6 +100,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { ): Promise => { const session = sessions.get(sessionId) if (session) { + // Settled and marked before the stop, so no start delivers it, and nothing it closed with + // sends by itself, even if the handle stays open. await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { mark: 'always' }) @@ -114,7 +118,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { return result } - const closeForWorkspaceSleep = ( + const closeForWorkspaceSleep = async ( sessionId: string, workspaceId: string, executionHostId: ExecutionHostId @@ -123,15 +127,17 @@ export function createStructuredAgentSessionConversationLifetime(host: { const location = deps().store.getRecord(sessionId)?.location return location?.workspaceId === workspaceId && location.executionHostId === executionHostId } + const starts = workspaceSleepStartAborts(() => host.context().runtimeState, sessionId) // Pending creates do not yet have a record; their queued close waits for founding to finish. if (matches()) { - host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + starts.abort() } - return serialize(sessionId, async () => { + const closed = serialize(sessionId, async () => { if (!matches()) { return null } readRefusals.forget(sessionId) + starts.abort() const record = deps().store.getRecord(sessionId) if (!record) { return null @@ -164,6 +170,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { // final identity check against a later restart. return { fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, stopped } }) + return starts.settle({ deps: deps(), sessions, sessionId }, await closed) } const idleSweep = new StructuredAgentSessionIdleSweep({ @@ -320,6 +327,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') return serialize(sessionId, async () => { readRefusals.forget(sessionId) + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') await holdStopClose(sessionId, () => stopAgent(sessionId, { cause })) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index a4cd751a877..60968511fc6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -33,6 +33,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { THREAD_ID, + acquireReadyCodexForTest, adapterFor, answerWithOpenedTurn, fakeCodex, @@ -99,6 +100,7 @@ beforeEach(async () => { } } }, + open: async () => ({ reportedOptions: { model: '' } }), dispatch, closeSession, acknowledgeSessionRelease, @@ -203,7 +205,7 @@ describe('a Stop that names no turn', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-catalog', @@ -215,6 +217,11 @@ describe('a Stop that names no turn', () => { cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) setOption.mockImplementation((input) => adapter.setOption(input)) + const listing = adapter.readOptions({ sessionId: SESSION, fence: 1 }) + const catalogReads = codex.connections[0].calls.filter( + (call) => call.method === 'model/list' + ).length + await pickOption('serviceTier', 'priority') const first = send('first send') await first.result await eventually(() => @@ -225,14 +232,12 @@ describe('a Stop that names no turn', () => { value: { options: { model: 'gpt-next' } } }) expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength( - 1 + catalogReads ) const followUp = send('follow-up') await followUp.result - await eventually(() => - expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(true) - ) + expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(false) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) codex.connections[0].handlers.onNotification?.('turn/completed', { threadId: THREAD_ID, @@ -250,6 +255,7 @@ describe('a Stop that names no turn', () => { codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params ).toMatchObject({ serviceTier: 'priority' }) pending.resolve({ data: [], nextCursor: null }) + await expect(listing).rejects.toThrow('no available models') }) // Codex can abort a turn before it starts: it answers the interrupt, then ends the turn it never @@ -275,7 +281,7 @@ describe('a Stop that names no turn', () => { return {} } const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted', @@ -317,7 +323,7 @@ describe('a Stop that names no turn', () => { return {} } const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted-item', @@ -346,7 +352,7 @@ describe('a Stop that names no turn', () => { const codex = fakeCodex() codex.routes['turn/start'] = () => ({ turn: { id: 'turn-1' } }) const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted-failed', @@ -386,7 +392,11 @@ describe('a Stop that names no turn', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-bad' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor(SESSION), + fence: 1, + spawnToken: 'spawn-bad' + }) dispatch.mockImplementation((input) => adapter.dispatch(input)) setOption.mockImplementation((input) => adapter.setOption(input)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index e10d215dc80..e393cbf0d15 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -11,6 +11,7 @@ import { AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../shared/agent-session-host-authority' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { openCodexAppServerConnection } from '../../codex/codex-app-server-connection' import { adapterFor, fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -22,6 +23,8 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, hostTestAttachParams, + hostTestMessage, + hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -64,17 +67,23 @@ function openStore(generation: HostGeneration): Promise return openTestAgentSessionRecordStore(generationRoot(generation)) } -/** A Codex adapter whose child spawns, reports its pid the way the real connection does, and then - * never answers the thread handshake: the host dies in that window. */ +/** A Codex adapter whose child spawns, reports its pid (the fake's `CHILD_PID`) the way the real + * connection does, and then never answers the thread handshake: the host dies in that window. */ function adapterThatNeverFinishesStarting(): StructuredAgentSessionAdapter { const codex = fakeCodex({ 'thread/start': () => new Promise(() => {}), 'thread/resume': () => new Promise(() => {}) }) + return Object.assign(adapterFor(codex), { supportsCreate: () => true }) +} + +/** A Codex adapter whose child spawns and reports its pid, and whose acquire never answers: the + * host dies with the child recorded and the create still unanswered. */ +function adapterThatNeverAnswersItsSpawn(): StructuredAgentSessionAdapter { + const codex = fakeCodex() const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { - const connection = await codex.openConnection(launch, handlers) - await handlers.onSpawned?.(CHILD_PID) - return connection + await codex.openConnection(launch, handlers) + return new Promise(() => {}) } return Object.assign(adapterFor({ ...codex, openConnection }), { supportsCreate: () => true }) } @@ -111,13 +120,15 @@ describe('a host that dies while its Codex child is starting', () => { const first = await openStore('dying') const dying = host('dying', first, adapterThatNeverFinishesStarting()) void dying.attach(CALLER, hostTestAttachParams(null)).catch(() => {}) - await vi.waitFor(() => expect(first.getRecord(SESSION)?.lease.ownerProcess).toBeTruthy()) - // Durable before the handshake returned: the only record the next host will have. - expect(first.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - ownerProcess: { pid: CHILD_PID, spawnToken: 'spawn-a' } - }) + // Durable before the handshake returned, which never does: the only record the next host will + // have. The child is published at spawn, so its lease is already live while it starts. + await vi.waitFor(() => + expect(first.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + ownerProcess: { pid: CHILD_PID, spawnToken: 'spawn-a' } + }) + ) // The relaunch. The orphan is still alive until something stops it. let orphanAlive = true @@ -167,10 +178,10 @@ describe('a create replayed after the host that ran it died', () => { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 it.each([ - ['its child was recorded', adapterThatNeverFinishesStarting, 0], + ['its child was recorded', adapterThatNeverAnswersItsSpawn, 0], [ 'its child was recorded, and its operation row has since expired', - adapterThatNeverFinishesStarting, + adapterThatNeverAnswersItsSpawn, PAST_OPERATION_EXPIRY ], ['nothing was recorded beyond the reservation', adapterThatNeverSpawns, 0], @@ -238,3 +249,54 @@ describe('a create replayed after the host that ran it died', () => { expect(restarted.connections).toHaveLength(1) }) }) + +// The dying host answered the create at spawn, but the answer was lost on its way to the client. +describe('a create answered before the host that ran it died', () => { + it('replays that answer with the chat at rest, and the next send starts the agent', async () => { + const params = hostTestAttachParams(null) + const first = await openStore('dying') + const dying = host('dying', first, adapterThatNeverFinishesStarting()) + await expect(dying.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) + + const restarted = fakeCodex() + await crash(first) + const store = await openStore('relaunched') + const relaunched = host( + 'relaunched', + store, + Object.assign(adapterFor(restarted), { supportsCreate: () => true }), + { mintSpawnToken: () => 'spawn-b', probeOwner: async () => ({ outcome: 'pid-absent' }) } + ) + await relaunched.restoreReadableSessions() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + + // Before, `ownerUnproven`: the replay tried to start an agent from a reservation long gone. + await expect(relaunched.attach(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { sessionId: SESSION, fence: 2 } + }) + expect(restarted.connections).toHaveLength(0) + + const body = hostTestMessage('after the lost answer') + const sent = await relaunched.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 2, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(restarted.connections).toHaveLength(1)) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + ownerProcess: { spawnToken: 'spawn-b' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 0bdc715f292..54c7cb76fa6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -168,6 +168,9 @@ function openHost(overrides: Partial): void { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(), dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -431,6 +434,9 @@ describe('a turn a read reached before the reconcile proved its owner dead', () const acquire = vi.fn() openHost({ adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -473,6 +479,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process, link: { linkId: `claude-${fence}-link`, @@ -486,6 +493,9 @@ describe('a turn a read reached before the reconcile proved its owner dead', () ) openHost({ adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -552,6 +562,7 @@ async function hostWithFailingFirstStart(failure: Error) { throw failure } return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process, link: { linkId: `claude-${fence}-link`, @@ -565,6 +576,9 @@ async function hostWithFailingFirstStart(failure: Error) { ) openHost({ adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, releaseAcquisition: async () => true, dispatch: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts index e6e05f91d6c..62e856a3a39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts @@ -210,12 +210,10 @@ it('holds the created first message until the child proves its start, then hands expect(held).toEqual([expect.objectContaining({ clientMessageId: first.clientMessageId })]) expect(held[0].handedOverAt).toBeUndefined() - await rig.host.handleAdapterEvent({ - type: 'started', + await rig.proveStart({ sessionId: SESSION, fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: generation, - reportedOptions: { model: 'gpt-live' } + acquisitionGeneration: generation }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index aa05c4ac120..039a9ed3c58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -30,6 +30,7 @@ import { observedReceipt } from './structured-agent-session-operation-settlement import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import { readAgentSessionHydrationPage } from './agent-session-history-page' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' export type StructuredAgentSessionCreateOptions = { firstMessage?: { clientMessageId: string; body: AgentJournalMessageItem } @@ -200,6 +201,7 @@ async function foundConversation( agent: params.agent, logger: deps.logger, resolvedBy: callerKey, + persistedOptions: candidate.record.options, persistOptions: async () => {}, publish: () => {}, operationReceipt: observedReceipt(store, store.founding.receipt(request)), @@ -297,7 +299,7 @@ export function structuredAgentSessionEntryDelegates( /** The model and effort the chat was created with, which its opening message runs on as a send's * carried ones do. */ function openingMessageOptions(options: Readonly> | undefined): { - options?: Readonly> + options?: AgentSessionExecutionOptions } { const { model, effort } = options ?? {} return model ? { options: { model, ...(effort ? { effort } : {}) } } : {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 8794560d7ca..c103bf2e4fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -5,15 +5,17 @@ // for a session exactly while a message is queued there — accepted, not yet handed over — and no // child is running a conversation command: a command's turn takes no input, and the commit that // ends it wakes the loop again. Every step re-reads the journal and the conversation's child -// record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop -// that arrives while a start holds the queue withdraw the queued messages before the handover that -// would have written them. Stop and the conversation's close are the only other writers of a +// record to decide, so there is no loop state to disagree with them. Each step is its own +// serialized task, and the waits between them — the agent's start, its readiness, the provider +// call — run outside the serialize, so a send is accepted while they last, and a Stop that arrives +// meanwhile withdraws the queued messages before the handover that would have written them. Stop +// and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. A message an // earlier host process left queued is never handed over: the open, or this loop's first step, // settles it first (`journal-unsent-send-hold.ts`). import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -21,18 +23,22 @@ import type { AgentSessionFailureWordsContext } from '../../../shared/agent-sess import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' import type { StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { + acceptedNoLaterThan, oldestQueuedSubmission, - startThatFailedWhileQueued, recordStructuredAgentSessionStartFailure, - refusedStructuredAgentSessionStart + refusedStructuredAgentSessionStart, + startThatFailedWhileQueued } from './structured-agent-session-start-failure-row' -import { handOverSubmission } from './structured-agent-session-turns' +import { + beginSubmissionHandover, + type StructuredAgentSessionPendingHandover +} from './structured-agent-session-handover' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -40,6 +46,9 @@ import type { StructuredAgentSessionStartReadiness } from './structured-agent-se import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' +import { waitsForCodexTurnOptions } from './structured-agent-session-message-options' +import { sameProviderChild } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' export type StructuredAgentSessionDeliveryLoopDeps = { deliveryOrder: StructuredAgentSessionDeliveryOrder @@ -49,28 +58,20 @@ export type StructuredAgentSessionDeliveryLoopDeps = { serialize: (sessionId: string, task: () => Promise) => Promise /** A start step, tracked from enqueue so quit waits for the child it may produce. */ trackStart: (start: Promise) => Promise - /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ - /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ - ensureProviderChild: ( - sessionId: string, - startedFor: string - ) => Promise + /** Claims the start for the queued head inside the ordered preparation. */ + startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim + /** Stops a child the chat outgrew (a new login or permission mode); inside `serialize`. */ + retireOutgrownChild: (sessionId: string) => Promise readiness: ( sessionId: string, child: StructuredAgentSessionProviderChildIdentity ) => StructuredAgentSessionStartReadiness | null - /** A message's model and effort, applied before its agent starts or it is handed over. */ - applyMessageOptions: ( - sessionId: string, - options: AgentJournalSubmission['options'] - ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number - /** Settles queued messages as a completed close of the chat does; false when that failed. */ - holdClosed: ( - sessionId: string, - which: (submission: AgentJournalSubmission) => boolean - ) => Promise + /** A close of this chat that stopped its child and then did not complete still closed what was + * queued before it, so no child starts for those: settled as a completed close does. Ordered, + * not latched: a later send goes on. False when those could not be closed. */ + closeWhatTheUserClosed: (sessionId: string) => Promise /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext logger: StructuredAgentSessionLogger @@ -84,18 +85,15 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** A person's Stop is still ending the session's work: the status feed's own reading. */ stopping: (sessionId: string) => boolean now: () => number + acquireAborts: Pick } -type Step = 'continue' | 'stop' - -type RefusedStart = Extract - type Prepared = | 'stop' - | RefusedStart + | { start: StructuredAgentSessionStartClaim; acceptedBy: AgentJournalCursor } | { ok: true - awaited: StructuredAgentSessionProviderChildIdentity | null + awaited: StructuredAgentSessionProviderChildIdentity readiness: StructuredAgentSessionStartReadiness | null before: Promise } @@ -153,16 +151,27 @@ export class StructuredAgentSessionDeliveryLoop { if (prepared === 'stop') { return } - if (!prepared.ok) { - if (prepared.aborted) { - // A close, Stop or quit stopped this start on purpose, and settled what it was for; a - // message accepted since gets its own start, and quit's next step stops the loop. + if ('start' in prepared) { + // Outside the serialize: sends are accepted while the agent starts. + const started = await prepared.start.outcome + if (started.ok || started.aborted) { + // Aborted: a close, Stop or quit stopped this start on purpose, and settled what it + // was for; a message accepted since gets its own start, and quit's next step stops the + // loop. Either way the next step re-derives from what the session holds now. continue } + // The start's refusal, as the failure every queued message it was for is rejected with. + const failure = refusedStructuredAgentSessionStart(this.deps.record(sessionId), started) + if (started.refusal.details?.reason !== 'previousExitUnverifiable') { + await this.deps.serialize(sessionId, () => this.fail(sessionId, failure)) + return + } + // An unproven close fails only what was queued when the start was asked for: a later + // ask runs the close again, so its verdict no longer holds for what came after. await this.deps.serialize(sessionId, () => - this.fail(sessionId, this.refusedStart(sessionId, prepared)) + this.recordFailure(sessionId, failure, prepared.acceptedBy) ) - return + continue } if (prepared.readiness) { await prepared.readiness.wait() @@ -174,6 +183,16 @@ export class StructuredAgentSessionDeliveryLoop { if (handed === 'stop') { return } + if (handed !== 'continue') { + // The provider call runs outside; its resolution is the next commit. Tracked, so quit's + // drain waits for both after its abort ends the call. + await this.deps.trackStart( + handed + .dispatch() + .then((resolve) => this.deps.serialize(sessionId, resolve)) + .finally(() => handed.release?.()) + ) + } } } catch (error) { // The error is Orca's own and goes to the log; the chat says only that Orca failed. @@ -212,7 +231,7 @@ export class StructuredAgentSessionDeliveryLoop { session.journal, this.deps.conversationFence(sessionId) ) - if (!(await this.closeWhatTheUserClosed(sessionId, session))) { + if (!(await this.deps.closeWhatTheUserClosed(sessionId))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) } @@ -227,33 +246,28 @@ export class StructuredAgentSessionDeliveryLoop { if (failedStart) { return this.fail(sessionId, failedStart) } - if (!session.child) { - await this.deps.applyMessageOptions(sessionId, oldest.options) - } - const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) - if (!ready.ok && ready.refusal.details?.reason === 'previousExitUnverifiable') { - // Failed in the step that was refused: a message accepted, or an exit proven, after it must - // not be failed for a verdict that no longer holds. - return this.fail(sessionId, this.refusedStart(sessionId, ready)) - } - if (!ready.ok) { - return ready - } + await this.deps.retireOutgrownChild(sessionId) const child = this.deps.sessions.get(sessionId)?.child + if (!child || child.close) { + return { + start: this.deps.startProviderChild(sessionId, oldest.clientMessageId), + acceptedBy: session.journal.cursor() + } + } // The child this run waits on; handover checks it is still the one there. return { ok: true, - awaited: child ? { generation: child.generation, fence: child.fence } : null, - readiness: child?.phase === 'starting' ? this.deps.readiness(sessionId, child) : null, + awaited: { generation: child.generation, fence: child.fence }, + readiness: child.phase === 'starting' ? this.deps.readiness(sessionId, child) : null, before: this.deps.deliveryOrder.beforeSend(sessionId, oldest.clientMessageId) } } private async handOver( sessionId: string, - awaited: StructuredAgentSessionProviderChildIdentity | null, + awaited: StructuredAgentSessionProviderChildIdentity, waited: boolean - ): Promise { + ): Promise<'continue' | 'stop' | StructuredAgentSessionPendingHandover> { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { return this.stop(sessionId) @@ -261,13 +275,10 @@ export class StructuredAgentSessionDeliveryLoop { // Re-derived here, not carried from the start: the child may have ended, or another may have // taken its place, since. const { child } = session - const awaitedChild = - child && awaited && child.generation === awaited.generation && child.fence === awaited.fence - ? child - : null + const awaitedChild = child && sameProviderChild(child, awaited) ? child : null if (!awaitedChild) { // The child started for this message is gone or replaced by another. - const ended = awaitedChild ? undefined : session.lastEndedChild + const ended = session.lastEndedChild const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined // A user's Stop or close is not a failure: the next step starts, or waits on, a child for // what is queued, after closing what a close of the chat closed. @@ -275,7 +286,7 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } return this.fail(sessionId, { - startKey: awaited?.generation ?? null, + startKey: awaited.generation, cause: endedFailure ?? // Gone with no end observed: nothing says the provider stopped. { failure: agentSessionFailureFact('startFailed') } @@ -295,16 +306,18 @@ export class StructuredAgentSessionDeliveryLoop { } if (waited) { // Re-check launch permission only after proving the awaited child still exists. - const ready = await this.deps.ensureProviderChild(sessionId, next.clientMessageId) - if (!ready.ok) { - return this.fail(sessionId, this.refusedStart(sessionId, ready)) - } + await this.deps.retireOutgrownChild(sessionId) if (session.child !== awaitedChild) { return 'continue' } } - await this.deps.applyMessageOptions(sessionId, next.options) - await handOverSubmission( + if ( + this.deps.record(sessionId)?.provider === 'codex' && + waitsForCodexTurnOptions(session.journal, next) + ) { + return this.stop(sessionId) + } + const pending = await beginSubmissionHandover( { sessionId, journal: session.journal, @@ -317,19 +330,27 @@ export class StructuredAgentSessionDeliveryLoop { childWork: () => this.deps.readChildWork(sessionId), childWorkOpen: () => this.deps.childWorkOpen(sessionId), now: this.deps.now, + acquireAborts: this.deps.acquireAborts, + deliveryOrder: this.deps.deliveryOrder, workChanged: () => this.deps.workChanged?.(sessionId) }, - next + next, + // Re-read right before the provider call, which runs outside the serialize. + () => this.deps.sessions.get(sessionId)?.child === awaitedChild && !awaitedChild.close ) - return 'continue' - } - - /** A start the session refused, as the failure every queued message it was for is rejected with. */ - private refusedStart(sessionId: string, refused: RefusedStart): StartFailure { - return refusedStructuredAgentSessionStart(this.deps.record(sessionId), refused) + return pending ?? 'continue' } private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { + await this.recordFailure(sessionId, failure) + return this.stop(sessionId) + } + + private async recordFailure( + sessionId: string, + failure: StartFailure, + acceptedBy?: AgentJournalCursor + ): Promise { const session = this.deps.sessions.get(sessionId) if (session) { await recordStructuredAgentSessionStartFailure( @@ -340,31 +361,10 @@ export class StructuredAgentSessionDeliveryLoop { failure.cause, this.deps.failureTextContext(sessionId) ) - } + }, + acceptedBy && acceptedNoLaterThan(session.journal, acceptedBy) ) } - return this.stop(sessionId) - } - - /** A close of this chat that stopped its child and then did not complete still closed what was - * queued before it, so no child starts for those. Ordered, not latched: a later send goes on. - * False when those could not be closed. */ - private async closeWhatTheUserClosed( - sessionId: string, - session: StructuredAgentSessionHostSession - ): Promise { - const ended = session.lastEndedChild - if (session.child || ended?.cause !== 'user-close') { - return true - } - const { epoch } = session.journal.cursor() - return this.deps.holdClosed( - sessionId, - (submission) => - ended.endedAt.epoch === epoch && - submission.acceptedSequence !== undefined && - submission.acceptedSequence <= ended.endedAt.sequence - ) } /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts index 8347f64c512..382b41a197e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts @@ -10,9 +10,10 @@ type Lane = { tail: Promise places: Set sends: Map + active: Set } -/** One delivery order; journal commits retire sends, bounded callers retire operations. */ +/** One delivery order; active handovers retain their place through settlement. */ export class StructuredAgentSessionDeliveryOrder { private readonly lanes = new Map() @@ -21,7 +22,7 @@ export class StructuredAgentSessionDeliveryOrder { const lane = this.lanes.get(sessionId) const ids = new Set(queued.map((submission) => submission.clientMessageId)) for (const [id, place] of lane?.sends ?? []) { - if (!ids.has(id)) { + if (!ids.has(id) && !lane?.active.has(id)) { lane?.sends.delete(id) place.release() } @@ -41,7 +42,8 @@ export class StructuredAgentSessionDeliveryOrder { const lane = this.lanes.get(sessionId) ?? { tail: Promise.resolve(), places: new Set(), - sends: new Map() + sends: new Map(), + active: new Set() } this.lanes.set(sessionId, lane) const done = Promise.withResolvers() @@ -64,13 +66,30 @@ export class StructuredAgentSessionDeliveryOrder { return this.lanes.get(sessionId)?.sends.get(messageId)?.before ?? Promise.resolve() } + activateSend(sessionId: string, messageId: string): () => void { + const lane = this.lanes.get(sessionId) + const place = lane?.sends.get(messageId) + lane?.active.add(messageId) + return () => { + lane?.active.delete(messageId) + lane?.sends.delete(messageId) + place?.release() + } + } + + hasActiveSend(sessionId: string): boolean { + return (this.lanes.get(sessionId)?.active.size ?? 0) > 0 + } + /** Queued messages keep their journal rows, but stop ordering operations when delivery parks. */ parkSends(sessionId: string): void { const lane = this.lanes.get(sessionId) - for (const place of lane?.sends.values() ?? []) { - place.release() + for (const [id, place] of lane?.sends ?? []) { + if (!lane?.active.has(id)) { + lane?.sends.delete(id) + place.release() + } } - lane?.sends.clear() } dispose(): void { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts deleted file mode 100644 index bbcadedabfe..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -// A record this build cannot drive (its agent's definition now names another transport or account -// variable, or the record pins another agent's variable) stays readable; only starting its agent is -// refused, at the one launch admission every agent passes through. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { StructuredAgentRegistry } from './structured-agent-registry' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentSessionHost } from './structured-agent-session-host' - -const NOW = 1_800_000_000_000 -const SESSION = 'grok-session' -const GROK_HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/home/dev/.grok' } -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -const GROK: StructuredAgentDefinition = { - ...CODEX_STRUCTURED_AGENT, - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME', - capabilities: { - ...CODEX_STRUCTURED_AGENT.capabilities, - rewind: false, - compact: false, - threadGoal: false - }, - restingOptions: { - acceptsKey: () => false, - fallbackModels: () => null, - effortDefaultsToModel: false - } -} - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the methods a declaration needs; this one declares none of them. -const NO_METHODS = {} as StructuredAgentSessionAdapter -const grokRegistered = (definition: StructuredAgentDefinition = GROK) => - new StructuredAgentRegistry([{ definition, adapter: NO_METHODS }]) - -function params( - operation: string, - expectedRuntimeFence: number | null, - accountHome = GROK_HOME -): AgentSessionAttachParams { - const attach: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-${operation.padStart(32, '0')}`, - expectedRuntimeFence, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'grok', - agent: 'grok', - accountHome, - runtimeKind: 'native' - } - return { - ...attach, - envelope: { - ...attach.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(attach) - }) - } - } -} - -function grokAdapter(): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - acquire: vi - .fn() - .mockImplementation(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `grok-link-${fence}`, - handle: { transport: 'acp', agent: 'grok', nativeId: 'grok-native-1' }, - origin: fence === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - })), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -async function attach(input: { - agents: StructuredAgentRegistry - adapter: StructuredAgentSessionAdapter - params: AgentSessionAttachParams - spawnToken: string - store?: AgentSessionRecordStore -}) { - const store = input.store ?? (await openTestAgentSessionRecordStore(root!)) - const result = await performAttach({ - agents: input.agents, - logger: createStructuredAgentSessionLogger(), - store, - adapter: input.adapter, - openConversation: async (record) => { - const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))(record) - return journal - }, - authority: { - spawnToken: input.spawnToken, - claimKeyId: 'key-1', - handoffOperationId: input.params.envelope.clientOperationId, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: input.params, - now: () => NOW, - onAttached: async (attached) => { - await attached.journal.close() - } - }) - return { result, store } -} - -/** Persist and reopen a Grok chat whose original child is gone. */ -async function createdGrokChat() { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const created = await attach({ - agents: grokRegistered(), - adapter: grokAdapter(), - params: params('1', null), - spawnToken: 'spawn-a' - }) - expect(created.result).toMatchObject({ ok: true }) - const store = await openTestAgentSessionRecordStore(root) - await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) - return { store, fence: store.getRecord(SESSION)!.lease.runtimeFence } -} - -const UNSUPPORTED = { - ok: false, - refusal: { code: 'structured_agent_session_unsupported', details: { reason: 'hostUnsupported' } } -} - -it('refuses an unregistered agent before creating a durable record', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - const { result, store } = await attach({ - agents: new StructuredAgentRegistry([]), - adapter, - params: params('1', null), - spawnToken: 'spawn-unregistered' - }) - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)).toBeNull() -}) - -it('keeps saved history readable without a registration and resumes when it returns', async () => { - const created = await createdGrokChat() - await created.store.setSessionTabVisibility(SESSION, true) - const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))( - created.store.getRecord(SESSION)! - ) - await journal.appendItem( - { provider: 'orca', clientMessageId: 'saved-reply' }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Saved reply' }] }, - { fence: created.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - - const store = await openTestAgentSessionRecordStore(root!) - const agents = new StructuredAgentRegistry([]) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - const acquire = vi.spyOn(router, 'acquire') - const host = new StructuredAgentSessionHost({ - store, - agents, - adapter: router, - journalDatabase: openTestJournalHostDatabase(root!), - claimKeyId: 'key-1', - probeOwner: async () => ({ outcome: 'pid-absent' }), - logger: createStructuredAgentSessionLogger(), - now: () => NOW + 2 - }) - try { - expect(router.supportsCreate(params('2', created.fence).location, 'grok')).toBe(false) - expect(store.listVisibleSessionIds()).toEqual([SESSION]) - await host.restoreReadableSessions([SESSION]) - expect(host.hasSession(SESSION)).toBe(true) - await expect(host.revealSession(SESSION)).resolves.toMatchObject({ - sessionId: SESSION, - agent: 'grok', - readable: true - }) - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history).toMatchObject({ - ok: true, - page: { - items: expect.arrayContaining([ - expect.objectContaining({ - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'Saved reply' }] - } - }) - ]) - } - }) - const before = store.getRecord(SESSION)!.lease.runtimeFence - await expect( - host.attach({ callerKey: 'client-1' }, params('2', before)) - ).resolves.toMatchObject(UNSUPPORTED) - expect(acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)!.lease.runtimeFence).toBe(before) - } finally { - await host.flushAllStreamedEvents() - } - - const adapter = grokAdapter() - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('3', store.getRecord(SESSION)!.lease.runtimeFence), - spawnToken: 'spawn-restored', - store - }) - expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() - expect(store.listVisibleSessionIds()).toEqual([SESSION]) -}) - -it.each([ - ['its transport', { ...GROK, handleTransport: 'grok-native' }], - ['its account variable', { ...GROK, accountHomeVariable: 'GROK_CONFIG_DIR' }] -])( - 'keeps a chat readable after its agent changes %s, and refuses to start it', - async (_change, changed) => { - const { store, fence } = await createdGrokChat() - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(changed), - adapter, - params: params('2', fence), - spawnToken: 'spawn-b', - store - }) - - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() - // Still listed and readable: its tab publishes and its history opens. - expect(store.getRecord(SESSION)).toMatchObject({ provider: 'grok', accountHome: GROK_HOME }) - expect(store.getRecord(SESSION)?.providerHandleChain[0]?.handle.transport).toBe('acp') - } -) - -it("refuses to start a chat that pins another agent's account variable", async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('1', null, { variable: 'CODEX_HOME', path: '/home/dev/.codex' }), - spawnToken: 'spawn-a' - }) - - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() -}) - -it('starts the same chat again once its agent is declared as it was', async () => { - const { store, fence } = await createdGrokChat() - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('2', fence), - spawnToken: 'spawn-b', - store - }) - - expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() -}) - -it('refuses an attach whose agent is not the session it names', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - const mismatched = { ...params('1', null), agent: 'codex' } - - const { result, store } = await attach({ - agents: grokRegistered(), - adapter, - params: { - ...mismatched, - envelope: { - ...mismatched.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(mismatched) - }) - } - }, - spawnToken: 'spawn-a' - }) - - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', details: { reason: 'requestMalformed' } } - }) - expect(adapter.acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)).toBeNull() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index a5d755b5c57..36ff991a425 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -10,10 +10,7 @@ import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import { - settleStructuredAgentSessionOptionsReported, - settleStructuredAgentSessionProviderStarted -} from './structured-agent-session-provider-started' +import { settleStructuredAgentSessionOptionsReported } from './structured-agent-session-provider-started' import { endExitedStructuredAgentSessionChildUnderSerialize, settleStructuredAgentSessionChildExit, @@ -67,6 +64,7 @@ export class StructuredAgentSessionEventRecovery { ...this.context, logger: deps.logger, startupAttempts: runtimeState.startupAttempts, + startFlights: runtimeState.startFlights, holdUnrunSends: async (sessionId, fence, cause) => { const journal = this.context.sessions.get(sessionId)?.journal if (journal) { @@ -133,9 +131,6 @@ export class StructuredAgentSessionEventRecovery { * nothing restarts the child. The next send does, through the delivery loop, which also owns any * message still queued. */ async handle(event: StructuredAgentSessionLifecycleEvent): Promise { - if (event.type === 'started') { - return settleStructuredAgentSessionProviderStarted(this.context, event) - } if (event.type === 'options-reported') { return settleStructuredAgentSessionOptionsReported(this.context, event) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts new file mode 100644 index 00000000000..b26b3788bf5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts @@ -0,0 +1,42 @@ +import { + readAgentSessionExecutionOptions, + type AgentSessionExecutionOptions +} from '../../../shared/agent-session-execution-options' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-options-read' + +/** Capture once, while acceptance holds the conversation's ordered lane: the one read of a send's + * `options` record into the choices its message keeps, else the chat's saved ones. */ +export function captureStructuredExecutionOptions( + ctx: Pick, + supplied?: Readonly> +): { options: AgentSessionExecutionOptions } | { refusal: AgentSessionWireRefusal } { + const saved = ctx.persistedOptions ?? {} + const candidate = { + ...(saved.model ? { model: saved.model } : {}), + ...(saved.effort ? { effort: saved.effort } : {}), + ...(saved.personality ? { personality: saved.personality } : {}), + ...(saved.fastMode ? { fastMode: saved.fastMode } : {}), + ...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}), + ...supplied + } + const options = readAgentSessionExecutionOptions(candidate) + if (!options) { + return { + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + 'This message has an invalid choice.' + ) + } + } + const refusal = structuredAgentSessionOptionOverridesRefusal( + ctx.agents, + ctx.agent, + Object.fromEntries( + Object.entries(options).filter(([key]) => !(ctx.agent === 'codex' && key === 'fastMode')) + ) + ) + return refusal ? { refusal } : { options } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index a469af94120..5404d277cc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -43,6 +43,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-failed-create-verdict-')) resetHostTestOperationIds() acquire = vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -58,6 +59,9 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, releaseAcquisition: vi.fn(async () => true), dispatch: vi.fn(async () => ({ state: 'admitted' as const })), @@ -179,4 +183,20 @@ describe('failed create owner verdict', () => { deathEvidence: null }) }) + + it('lets the sink a failed create wrote through go, so the next create and shutdown proceed', async () => { + acquire.mockImplementationOnce(async ({ events }) => { + // The child's exit reached the translator before any journal was attached. + events?.setActivity?.(null) + throw new Error(EXIT_REASON) + }) + + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: false + }) + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) + await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts deleted file mode 100644 index e510916b097..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -// A child that dies between spawn and journal attach can still write through the host's event -// sink, which attach unbound and never re-bound. That queue must die with the failed create, or -// the next attach's drain barrier and shutdown's flush wait on it forever. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - AgentSessionPreSpawnError, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } -const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-failed-create-sink-')) - resetHostTestOperationIds() - acquire = vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - // A resume continues the chain the first start created. - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: `generation-${fence}` - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire, - releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - await rm(root, { recursive: true, force: true }) -}) - -describe('a create that fails after its child wrote through the unbound sink', () => { - it.each([ - // The common failed start: answered as a refusal. - ['refused', new Error(EXIT_REASON)], - // A failure the attach cannot classify still throws, and must release the sink too. - ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] - ])( - 'releases the sink when %s, so a new create and shutdown both proceed', - async (_how, cause) => { - acquire.mockImplementationOnce(async ({ events }) => { - // The published child's exit reached the translator before any journal was attached. - events?.setActivity?.(null) - throw cause - }) - - const failed = host.attach(CALLER, hostTestAttachParams(null)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ - ok: false, - refusal: { message: "Codex couldn't restart. Send your message to try again." } - })) - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() - expect(acquire).toHaveBeenCalledTimes(2) - } - ) - - it.each([ - ['refused', new Error(EXIT_REASON)], - ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] - ])('releases the sink when a resume of a still-indexed session is %s', async (_how, cause) => { - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: `generation-${exitedFence}` - }) - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockImplementationOnce(async ({ events }) => { - events?.setActivity?.(null) - throw cause - }) - - // The session stays indexed across this failure: it is a resume, not a create. - const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ ok: false })) - - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await expect(host.attach(CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ - ok: true - }) - expect(acquire).toHaveBeenCalledTimes(3) - // Only the adopted child's sink still takes writes: the failed attempt's closed with it, and - // the exited generation's closed when the resume replaced it. - const [exited, failedAttempt, resumed] = acquire.mock.calls.map(([input]) => input.events) - expect(failedAttempt?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) - expect(exited?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) - expect(resumed?.tryPublish?.()).toEqual({ accepted: true }) - await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 6fad454e993..cf93b60c98f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -191,6 +191,7 @@ function attachContext( adoptEventSink: () => undefined, probeOwner: async () => ({ outcome: 'pid-absent' }), discardEventSink: () => undefined, + startFlights: new Map(), acquireAborts: new StructuredAgentSessionAcquireAborts() }, sessions, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts deleted file mode 100644 index f308b790398..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -const attachParams = (): AgentSessionAttachParams => hostTestAttachParams(null) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let answerPrompt: Mock -let ordinal = 0 - -function adapter(): StructuredAgentSessionAdapter { - const dispatch = vi.fn(async (): Promise => { - ordinal += 1 - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } - } - }) - return { - acquire, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt, - setOption: vi.fn(async () => undefined) - } -} - -async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { - const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 100 } - const events = acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('seedGroupedQuestion requires an acquired session') - } - events.appendItem( - identity, - { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) - const itemId = agentJournalItemKey(identity) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null - if (!appended) { - throw new Error('provider question was not written to the journal') - } - return { itemId, revision: appended.revision } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-wire-grouped-')) - resetHostTestOperationIds() - ordinal = 0 - acquire = vi.fn(async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - answerPrompt = vi.fn(async ({ commit }) => commit()) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('grouped question admission', () => { - it('reads the packed answer an older client sends into structured answers', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const answers = [ - { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - const optionId = encodeAgentSessionQuestionAnswers(answers) - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - expect(result).toMatchObject({ - ok: true, - value: { resolution: { state: 'resolved', selectedOptionId: optionId, answers } } - }) - expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ itemId: prompt.itemId, response: { kind: 'answers', answers } }) - ) - }) - - it('takes structured answers past the old option-id bound and keeps the packed form for older readers', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const typed = 'Proceed with the replacement, but wait for the capture. '.repeat(40) - const answers = [ - { questionId: 'q1', optionIds: ['target-web'] }, - { questionId: 'q2', optionIds: [], other: typed } - ] - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - - expect(typed.length).toBeGreaterThan(1024) - expect(result).toMatchObject({ - ok: true, - value: { - resolution: { - state: 'resolved', - selectedOptionId: encodeAgentSessionQuestionAnswers(answers), - answers - } - } - }) - expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ response: { kind: 'answers', answers } }) - ) - }) - - it('refuses answers that do not match the questions without reaching the provider', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const answers = [{ questionId: 'q1', optionIds: ['target-web'] }] - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect(answerPrompt).not.toHaveBeenCalled() - }) -}) - -describe('a grouped question answered again', () => { - const answers = [ - { questionId: 'q1', optionIds: ['target-web'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - - async function answer( - prompt: { itemId: string; revision: number }, - given: typeof answers - ): ReturnType { - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers: given } - return host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - } - - it('answers a re-click with the same answers from the resolution it holds', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) - const prompt = await seedGroupedQuestion() - const first = await answer(prompt, answers) - - // The reply to the first was lost, so the re-click still names the revision it saw. - const again = await answer(prompt, answers) - - expect(first.ok).toBe(true) - expect(again).toEqual({ ...first, replayed: false }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) - - it('refuses different answers to a resolved question as moved on', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) - const prompt = await seedGroupedQuestion() - await answer(prompt, answers) - - const different = await answer(prompt, [ - { questionId: 'q1', optionIds: ['target-mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ]) - - expect(different).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - details: { reason: 'promptMoved' }, - resolution: { answers } - } - }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts index 64cb1fe0616..932ab39621d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts @@ -22,6 +22,20 @@ export async function whileStructuredAgentSessionHandingOver( handOver: (released: () => void) => Promise, workChanged?: () => void ): Promise { + const released = beginStructuredAgentSessionHandover(journal, clientMessageId, workChanged) + try { + return await handOver(released) + } finally { + released() + } +} + +/** The same held window across the host's separate pending-row and provider-call steps. */ +export function beginStructuredAgentSessionHandover( + journal: HandoverJournal, + clientMessageId: string, + workChanged?: () => void +): () => void { let sends = handingOver.get(journal) if (!sends) { sends = new Set() @@ -29,16 +43,11 @@ export async function whileStructuredAgentSessionHandingOver( } sends.add(clientMessageId) let held = true - const released = (): void => { + return (): void => { if (held) { held = false sends.delete(clientMessageId) workChanged?.() } } - try { - return await handOver(released) - } finally { - released() - } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts new file mode 100644 index 00000000000..eefe8711a19 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -0,0 +1,229 @@ +// Handing one accepted message to the provider: two commits around a provider call that runs +// outside the session's serialize, so a send is accepted while the call is in flight. + +import { + agentSessionFailureFact, + providerDiagnosticOf +} from '../../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' +import { abortSignalReason } from '../../../shared/abort-signal-reason' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { + AgentJournalMessageItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' +import type { AgentSessionDispatchOutcome } from './structured-agent-session-adapter' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + handOverStructuredAgentSessionCommand, + structuredAgentSessionHandoverOrigin, + type StructuredAgentSessionCommandHandoverContext +} from './structured-agent-session-command-turn' +import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' +import { carriedStoppedHistory } from './structured-agent-session-stopped-history-selection' +import { withStoppedHistory } from '../../../shared/agent-session-stopped-history' +import { + beginStructuredAgentSessionHandover, + whileStructuredAgentSessionHandingOver +} from './structured-agent-session-handing-over' + +export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { + acquireAborts?: Pick + deliveryOrder?: StructuredAgentSessionDeliveryOrder + workChanged?: () => void +} + +/** A handover whose pending row and abort handle are committed: the provider call runs outside the + * session's serialize and answers with the commit that resolves it. */ +export type StructuredAgentSessionPendingHandover = { + dispatch: () => Promise<() => Promise> + release?: () => void +} + +/** + * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash + * after it leaves a message in doubt, never one that reads as queued and so provably unwritten. + * The abort handle a Stop reaches the call by is begun before that row, in the same step, so no + * Stop falls between them. Null when the handover finished in this step (a command, a lost body). + */ +export async function beginSubmissionHandover( + ctx: AgentSessionHandoverContext, + submission: AgentJournalSubmission, + /** Whether the child the row was written for is still the one to call. */ + stillCurrent: () => boolean +): Promise { + const { clientMessageId } = submission + const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) + if (body?.kind !== 'message') { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), + fence: ctx.fence + }) + return null + } + if (body.command) { + await whileStructuredAgentSessionHandingOver( + ctx.journal, + clientMessageId, + async () => handOverStructuredAgentSessionCommand(ctx, submission, body), + ctx.workChanged + ) + return null + } + // The message joins the turn the child it goes to is running, a steer, or opens its own; never a + // turn an ended generation left. A new turn carries the stopped messages owed to the agent, named + // on this same row before anything is written. + const turnScope = contextStructuredAgentSessionCurrentWork(ctx).turnScope() + const carried = carriedStoppedHistory(ctx, turnScope, submission, body) + const release = ctx.deliveryOrder?.activateSend(ctx.sessionId, clientMessageId) + // The pending row precedes the adapter's own report of which sends it holds. + const released = beginStructuredAgentSessionHandover( + ctx.journal, + clientMessageId, + ctx.workChanged + ) + const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) + try { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope, + ...(carried ? { carries: carried.covers } : {}) + }) + } catch (error) { + released() + release?.() + attempt?.end() + throw error + } + // The handover row's instant on the host clock; the turn this dispatch opens records it so the + // live counter never re-anchors at turn-open. + const requestedAt = structuredAgentSessionHandoverOrigin(ctx.journal, submission) + return { + release: () => { + released() + release?.() + }, + dispatch: async () => { + let outcome: AgentSessionDispatchOutcome | null + try { + outcome = attempt?.signal.aborted + ? cancelledDispatch() + : // A child gone since the row: its end settles the row, and nothing reaches the wire. + stillCurrent() + ? await dispatchSafely( + ctx, + clientMessageId, + carried ? withStoppedHistory(body, carried.messages) : body, + requestedAt, + submission.options, + attempt?.signal + ) + : null + } finally { + attempt?.end() + } + return () => resolveHandover(ctx, clientMessageId, outcome) + } + } +} + +async function resolveHandover( + ctx: AgentSessionHandoverContext, + clientMessageId: string, + outcome: AgentSessionDispatchOutcome | null +): Promise { + // An admission needs no dispatch row: the submission is already pending. + if (!outcome || outcome.state === 'admitted') { + return + } + try { + await ctx.journal.resolveDispatch( + outcome.state === 'accepted' + ? { + clientMessageId, + state: 'accepted', + providerIdentity: outcome.providerIdentity, + fence: ctx.fence + } + : outcome.state === 'rejected' + ? { + clientMessageId, + state: 'rejected', + reason: outcome.reason, + rejection: outcome.rejection, + ...(outcome.answeredInTurn ? { answeredInTurn: outcome.answeredInTurn } : {}), + fence: ctx.fence + } + : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } + ) + } catch (error) { + // A failed resolution must not strand a pending row; an unknown result is + // explicitly replayable. + try { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'unknown', + reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, + fence: ctx.fence + }) + } catch { + // Nothing further to record; the pending row is settled on the next open. + } + throw error + } +} + +/** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown`, never + * replayed. Only a child that proved its start is handed anything, so a start that fails leaves its + * messages unsent instead (the delivery loop's barrier); an adapter that knows a write never + * happened answers `rejected`. */ +async function dispatchSafely( + ctx: AgentSessionHandoverContext, + clientMessageId: string, + body: AgentJournalMessageItem, + requestedAt: number, + executionOptions: AgentSessionExecutionOptions | undefined, + signal?: AbortSignal +): Promise { + try { + const outcome = await ctx.adapter.dispatch({ + sessionId: ctx.sessionId, + clientMessageId, + body, + ...(executionOptions ? { executionOptions } : {}), + ...(signal ? { signal } : {}), + fence: ctx.fence, + requestedAt + }) + return signal?.aborted && outcome.state === 'rejected' ? cancelledDispatch() : outcome + } catch (error) { + if (signal?.aborted && error === abortSignalReason(signal)) { + return cancelledDispatch() + } + if (isAgentSessionOptionRejectedError(error)) { + return { + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }), + { surface: 'rejection' } + ) + } + } + return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } + } +} + +function cancelledDispatch(): AgentSessionDispatchOutcome { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts index ebcd15553a7..969a3346c1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -1,64 +1,52 @@ -// A fake adapter's children prove their start once published, except those a test holds `starting`. +// Fake protocol opens use the same awaited host path as production adapters. -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' type Acquire = StructuredAgentSessionAdapter['acquire'] +type Open = StructuredAgentSessionAdapter['open'] export type StructuredAgentSessionHeldStarts = { - /** Wraps an acquire so the child it answers stays `starting`. */ held(acquire: Acquire): Acquire - /** Wraps the adapter's acquire: every child not held proves its start once published. */ - wrap(adapter: A): A - /** Answers the pending protocol open. */ + wrap(adapter: A): A & StructuredAgentSessionAdapter prove(child: { sessionId: string; fence: number; acquisitionGeneration: string }): Promise clear(): void } export function structuredAgentSessionHeldStarts( - _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } + _host: () => unknown ): StructuredAgentSessionHeldStarts { - const heldGenerations = new Map>() - let generations = 0 + const held = new Map void>() + const waiting = new Map>() const prove: StructuredAgentSessionHeldStarts['prove'] = async (child) => { - heldGenerations.get(child.acquisitionGeneration)?.resolve() + held.get(child.acquisitionGeneration)?.() await Promise.resolve() } return { held: (acquire) => async (input) => { const acquired = await acquire(input) - const acquisitionGeneration = acquired.acquisitionGeneration ?? `held-start-${++generations}` - heldGenerations.set(acquisitionGeneration, Promise.withResolvers()) - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + const generation = acquired.acquisitionGeneration + waiting.set(generation, new Promise((resolve) => held.set(generation, resolve))) + return acquired }, wrap: (adapter) => { - const acquire: Acquire = async (input) => { - const acquired = await adapter.acquire(input) - const acquisitionGeneration = - acquired.acquisitionGeneration ?? `instant-start-${++generations}` - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } - } - const open: NonNullable = async (input) => { - if (input.acquisitionGeneration) { - await heldGenerations.get(input.acquisitionGeneration)?.promise - heldGenerations.delete(input.acquisitionGeneration) + const open: Open = async (input) => { + const generation = input.acquisitionGeneration + if (generation) { + await waiting.get(generation) + waiting.delete(generation) + held.delete(generation) } - return adapter.open - ? adapter.open(input) - : { - reportedOptions: { model: '' } - } + return adapter.open(input) } - return { ...adapter, acquire, open } + return { ...adapter, open } }, prove, clear: () => { - for (const held of heldGenerations.values()) { - held.resolve() + for (const resolve of held.values()) { + resolve() } - heldGenerations.clear() + held.clear() + waiting.clear() } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 974fa2111ac..dba7c8ca96a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -16,7 +16,7 @@ import type { StructuredAgentSessionClientDelivery } from './structured-agent-se import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionHostDeps, @@ -28,8 +28,8 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-se import { recoverStructuredRewind } from './structured-rewind-recovery' import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' -import { applyStructuredAgentSessionMessageOptions } from './structured-agent-session-message-options' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import { acceptedNoLaterThan } from './structured-agent-session-start-failure-row' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -56,14 +56,12 @@ export type StructuredAgentSessionConversationDelivery = { export function createStructuredAgentSessionConversationDelivery(input: { deps: StructuredAgentSessionHostDeps + acquireAborts: Pick sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise trackStart: (start: Promise) => Promise - /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ - ensureProviderChild: ( - sessionId: string, - startedFor: string - ) => Promise + /** Claims the queued head's start during its ordered preparation. */ + startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim deliveryOrder: StructuredAgentSessionDeliveryOrder readiness: ( sessionId: string, @@ -71,8 +69,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { ) => StructuredAgentSessionStartReadiness | null /** Stops a child the chat outgrew, so the start after it reads a new login or permission mode. */ stopOutgrownAgent: (sessionId: string) => Promise - /** Read lazily: the host's mutation context is still assembling when this is built. */ - mutationContext: () => StructuredAgentSessionMutationContext clientDelivery: Pick< StructuredAgentSessionClientDelivery, | 'publishRestored' @@ -90,7 +86,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { agents: deps.agents, serialize: input.serialize, trackStart: input.trackStart, - ensureProviderChild: async (sessionId, startedFor) => { + startProviderChild: input.startProviderChild, + retireOutgrownChild: async (sessionId) => { // A send waiting on a person's Stop is not handed over yet; the step after the Stop decides. const session = input.clientDelivery.readStopping(sessionId) ? undefined @@ -107,7 +104,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { }, providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true }, - startUnavailable: () => deps.adapter.startUnavailable?.(sessionId), launchPermission: async () => { const record = deps.store.getRecord(sessionId) return record ? structuredAgentSessionLaunchPermission(deps, record) : null @@ -115,22 +111,20 @@ export function createStructuredAgentSessionConversationDelivery(input: { stopAgent: input.stopOutgrownAgent, logger: deps.logger }) - return input.ensureProviderChild(sessionId, startedFor) }, readiness: input.readiness, deliveryOrder: input.deliveryOrder, - applyMessageOptions: (sessionId, options) => - applyStructuredAgentSessionMessageOptions(input.mutationContext(), sessionId, options), conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), - holdClosed: async (sessionId, which) => { + closeWhatTheUserClosed: async (sessionId) => { const session = sessions.get(sessionId) - return session - ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, { + const ended = session?.lastEndedChild + return !session || session.child || ended?.cause !== 'user-close' + ? true + : holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, { mark: 'settled', - which + which: acceptedNoLaterThan(session.journal, ended.endedAt) }) - : true }, failureTextContext: (sessionId) => structuredAgentSessionFailureWordsContext( @@ -145,7 +139,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { workChanged: (sessionId) => input.clientDelivery.publishGenerationEnded(sessionId), childWorkOpen: input.clientDelivery.childWorkOpen, stopping: input.clientDelivery.readStopping, - now: () => deps.now?.() ?? Date.now() + now: () => deps.now?.() ?? Date.now(), + acquireAborts: input.acquireAborts }) const adoptOpened = async ( sessionId: string, @@ -228,7 +223,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { * A rewind found prepared when the conversation opens was started under a child this process no * longer has — the open runs only when none is indexed — so nothing will finish it, and left alone * it refuses every send until a view attaches. Settled here instead of by a start inside - * acceptance. A Codex rewind only its provider can prove stays for the attach. + * acceptance. A Codex rewind only its provider can prove stays for the next proven start. */ async function settleInterruptedCommands( deps: StructuredAgentSessionHostDeps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts deleted file mode 100644 index 8669a3c92d2..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { probeAgentSessionProcessIdentity } from '../../runtime/agent-session-process-identity-probe' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - closeTestJournalHostDatabase, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams -} from './structured-agent-session-host-test-data' -import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' - -const RENEW_INTERVAL_MS = 10_000 -const roots: string[] = [] - -afterEach(async () => { - vi.useRealTimers() - for (const root of roots) { - closeTestJournalHostDatabase(root) - } - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -/** A real host whose adapter spawned a child it could not read a start time for. */ -async function hostWithStartTimeLessChild(platform: NodeJS.Platform) { - const root = await mkdtemp(join(tmpdir(), 'orca-host-held-renewal-')) - roots.push(root) - const store = await openTestAgentSessionRecordStore(root) - const clock = { now: NOW } - const process = { live: true } - const holdsLiveProviderProcess = vi.fn( - (_sessionId: string, generation: string) => process.live && generation === 'gen-1' - ) - const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: null, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: 'gen-1' - }), - holdsLiveProviderProcess, - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } - // The real PID probe with the pid present: no start time and no token echo to match. - const probeOwner = vi.fn((record: AgentSessionRecord) => - probeAgentSessionProcessIdentity({ - identity: record.lease.ownerProcess!, - deps: { platform, isPidPresent: () => true } - }) - ) - const host = new StructuredAgentSessionHost({ - logger: recordingProductionStructuredAgentSessionLogger().logger, - store, - adapter, - agents: claudeAndCodexDeclared(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner, - now: () => clock.now - }) - return { host, store, clock, process, probeOwner, holdsLiveProviderProcess } -} - -describe.each(['darwin', 'win32'] as const)('host lease renewal on %s', (platform) => { - it('renews a held child without a PID probe until the adapter sees its root exit', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) - const { host, store, clock, process, probeOwner, holdsLiveProviderProcess } = - await hostWithStartTimeLessChild(platform) - const attached = await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null)) - expect(attached.ok).toBe(true) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - - clock.now = NOW + RENEW_INTERVAL_MS - await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) - await vi.waitFor(() => - expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) - ) - expect(probeOwner).not.toHaveBeenCalled() - expect(holdsLiveProviderProcess).toHaveBeenCalledWith(SESSION, 'gen-1') - - // The adapter saw the root exit; the host has not settled it, so its child is still on record. - process.live = false - clock.now = NOW + 2 * RENEW_INTERVAL_MS - await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) - await vi.waitFor(() => expect(probeOwner).toHaveBeenCalledOnce()) - - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) - expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) - await host.flushAllStreamedEvents() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts index 881a05ef78a..0794f53fa68 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts @@ -38,8 +38,8 @@ export function createStructuredAgentSessionHostMutationContext( childWorkIncomplete: input.clientDelivery.childWorkIncomplete, serialize: input.serialize, openConversation: input.conversationDelivery.open, - ensureAgent: (sessionId) => - agentStart.ensureStructuredAgentSessionAgentForOperation(input.attachContext(), sessionId), + claimAgent: (sessionId) => agentStart.claimOperationStart(input.attachContext(), sessionId), + startFlights: input.runtimeState.startFlights, startupAttempts: input.runtimeState.startupAttempts, deliveryOrder: input.runtimeState.deliveryOrder, joinChildClose: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 31de6353827..a324b34b742 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -7,7 +7,6 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. -import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, @@ -55,9 +54,9 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho import { readStructuredAgentSessionOptions, IN_PROCESS_OPTIONS_READER, + recordStructuredAgentSessionOptionIntent, type StructuredAgentSessionOptionsReader } from './structured-agent-session-options-read' -import { writeStructuredAgentSessionOption } from './structured-agent-session-message-options' import { settleStructuredAgentSessionLateDispatch } from './structured-agent-session-late-dispatch' import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' @@ -149,12 +148,13 @@ export function cancelStructuredAgentSessionTurn( } const plan = cancelPlan(params) const { prompt } = params - if (!prompt && params.turnId === undefined) { - abortAcquireForStop(context, caller, params.envelope, plan) - } + const stoppedPrePrompt = + !prompt && params.turnId === undefined + ? abortProviderWaitForStop(context, caller, params.envelope, plan) + : false // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). - const stopped = prompt ? { envelope: params.envelope } : params + const stopped = prompt ? { envelope: params.envelope } : { ...params, stoppedPrePrompt } return mutateWithChatStop(context, caller, stopped, plan, (ctx, stop) => prompt ? cancelStructuredAgentSessionPrompt( @@ -170,12 +170,12 @@ export function cancelStructuredAgentSessionTurn( * queue is waiting on stops now, as a close's does, and the Stop's own step then finds no child. * Only a Stop admission would run now; one naming a turn is about a child already gone, so it * leaves a newer start alone. */ -function abortAcquireForStop( +function abortProviderWaitForStop( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, plan: MutationPlan -): void { +): boolean { const { store } = context.deps if ( !agentSessionMutationAdmitsNow({ @@ -186,9 +186,9 @@ function abortAcquireForStop( now: context.now }) ) { - return + return false } - context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') + return context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') } export function respondToStructuredAgentSessionPrompt( @@ -210,41 +210,17 @@ export async function setStructuredAgentSessionOption( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { - const recordOnly = params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID const plan = setOptionPlan(params) - const atRest = () => { - const child = context.sessions.get(params.envelope.sessionId)?.child - return recordOnly || !child || child.phase === 'starting' || child.close !== undefined - } - return runWithReadyStructuredAgentSessionOperation( + return mutateStructuredAgentSession( context, - caller.callerKey, + caller, params.envelope, - plan, - (selection) => - runStructuredAgentSessionMutation( - context, - caller, - params.envelope, - { - ...plan, - get conversationWrite() { - return atRest() ? (true as const) : undefined - }, - run: (ctx) => writeStructuredAgentSessionOption(context, ctx, params) - }, - (ledger) => - atRest() - ? openForWrite(context, params.envelope)() - : openWithReadyStructuredAgentSessionChild(context, params.envelope, selection)(ledger) - ), { - needsAgent: () => { - const child = context.sessions.get(params.envelope.sessionId)?.child - return !recordOnly && Boolean(child && !child.close) - }, - runAfterAgentEnds: true - } + ...plan, + conversationWrite: true, + run: (ctx) => recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + }, + openForWrite(context, params.envelope) ) } @@ -264,10 +240,11 @@ export async function changeStructuredAgentSessionThreadGoal( context, caller, params.envelope, - plan, + threadGoalPlan({ ...params, signal: selection.signal }), openWithReadyStructuredAgentSessionChild(context, params.envelope, selection) ), { + goal: true, beforeAgent: (record) => refuseThreadGoalUnsupported(context.deps.adapter, context.deps.agents, record?.provider) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 04a1ade9f10..4b776f1dcf3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -8,6 +8,7 @@ import { } from './structured-agent-session-event-sink' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { heldProviderChildReader, @@ -17,14 +18,15 @@ import { resolveStructuredSessionRecovery } from './structured-agent-session-rec import { recordAgentSessionStartup } from '../../observability/agent-session-instrumentation' import { StructuredAgentSessionStartupAttempts, - StructuredAgentSessionStartupExpiredError, type StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' +import { StructuredAgentSessionStartupExpiredError } from './structured-agent-session-startup-attempt-contract' export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() readonly deliveryOrder = new StructuredAgentSessionDeliveryOrder() readonly acquireAborts = new StructuredAgentSessionAcquireAborts() + readonly startFlights = new StructuredAgentSessionStartFlights() readonly startupAttempts: StructuredAgentSessionStartupAttempts private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts deleted file mode 100644 index f8f9745f890..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// The helper is what the restart specs remove their temp directory behind, so what it waits for is -// load-bearing: a store commit that lands afterwards re-creates the directory it just removed. - -import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -describe('abandoning a structured agent-session host', () => { - it('waits for the restart the delivery loop woke for an accepted send', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-abandon-')) - const store = await openTestAgentSessionRecordStore(root) - // Holds the restart inside its provider acquisition, so the point teardown must not run past - // is exact rather than a timing window. - let gate: Promise | null = null - let openGate = (): void => {} - let reportEntered = (): void => {} - const entered = new Promise((resolve) => { - reportEntered = resolve - }) - const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence }) => { - if (gate) { - reportEntered() - await gate - } - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - releaseAcquisition: async () => true, - setOption: async () => undefined - } - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - probeOwner: async () => ({ - outcome: 'identity-matched', - matchedOn: ['process-start-time'] - }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - // The conversation stays and its provider child does not, so the next send makes the delivery - // loop start one — the shape the refusal-oracle spec ends on. - await host.close(SESSION, 'evict') - gate = new Promise((resolve) => { - openGate = resolve - }) - - const body = hostTestMessage('delivery loop') - expect( - await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-000000000000000000000000000003e9`, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - ).toMatchObject({ ok: true }) - await entered - - let abandoned = false - const abandoning = abandonStructuredAgentSessionHost(host).then(() => { - abandoned = true - }) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(abandoned).toBe(false) - - openGate() - await abandoning - - // Nothing is left to put the directory back after this returns. - await rm(root, { recursive: true }) - await new Promise((resolve) => setTimeout(resolve, 200)) - expect(existsSync(root)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index d4fb189ede0..1f02b435bbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -87,6 +87,9 @@ function adapter(): StructuredAgentSessionAdapter { return startsWhenPublished( { acquire, + open: async () => ({ + reportedOptions: { model: '' } + }), releaseAcquisition, dispatch: async (input) => { await input.beforeDispatch?.() @@ -139,6 +142,7 @@ beforeEach(async () => { resetHostTestOperationIds() ordinal = 0 acquire = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 28c3951e5dc..29bf3d3ab55 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -69,7 +69,7 @@ export type StructuredAgentSessionChildClose = { /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { - /** A starting acquisition stays `starting` until the host settles its `started` event; only then is + /** Every acquired child is `starting` until the host settles its open result; only then is * it handed input and are its reported options fact. */ phase: StructuredAgentSessionProviderChildPhase /** The queued message whose delivery started this child, fixed when the start is made; absent diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 213b62c4bb8..f6d1a15380a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusEvent, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -139,7 +142,8 @@ describe('attach', () => { origin: 'created', mintedAtFence: fence + 1, observedAt: NOW - } + }, + acquisitionGeneration: 'generation-stale' })) .mockImplementation(async ({ fence, spawnToken }) => ({ process: { @@ -154,7 +158,8 @@ describe('attach', () => { origin: 'created', mintedAtFence: fence, observedAt: NOW - } + }, + acquisitionGeneration: `generation-${fence}` })) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, @@ -527,7 +532,7 @@ describe('respondToPrompt', () => { }) describe('setOption', () => { - it('goes to the provider and writes nothing to the journal', async () => { + it('saves a next-message pick without changing the provider', async () => { await attach() setOption.mockResolvedValueOnce({ model: 'gpt-5', effort: 'high' }) const fields = { key: 'model', value: 'gpt-5' } @@ -538,20 +543,20 @@ describe('setOption', () => { const result = await host.setOption(CALLER, params) expect(result).toMatchObject({ ok: true, - value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + value: { ...fields, options: { model: 'gpt-5' } } }) expect(await host.setOption(CALLER, params)).toMatchObject({ ok: true, replayed: true, - value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + value: { ...fields, options: { model: 'gpt-5' } } }) - expect(setOption).toHaveBeenCalledTimes(1) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5', effort: 'high' }) + expect(setOption).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5' }) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(0) }) - it('does not turn an unknown provider outcome into a successful replay', async () => { + it('replays the accepted pick without contacting the provider', async () => { await attach() setOption.mockRejectedValueOnce(new Error('reply lost')) const fields = { key: 'model', value: 'gpt-5' } @@ -560,12 +565,12 @@ describe('setOption', () => { ...fields } - await expect(host.setOption(CALLER, params)).rejects.toThrow('reply lost') + expect(await host.setOption(CALLER, params)).toMatchObject({ ok: true }) expect(await host.setOption(CALLER, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown' } + ok: true, + replayed: true }) - expect(setOption).toHaveBeenCalledTimes(1) + expect(setOption).not.toHaveBeenCalled() }) }) @@ -625,8 +630,15 @@ describe('restart', () => { await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' })) acquire.mockClear() const listRecords = vi.spyOn(store, 'listRecords') + // A list already on the stream hears of a chat restored without a child only from the restore. + const listed: AgentSessionStatusEvent[] = [] + host.subscribeStatus({ id: 'session-list', emit: (event) => listed.push(event) }) await host.restoreReadableSessions() + expect(listed.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) + }) const restoreReads = listRecords.mock.calls.length await host.restoreReadableSessions() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 1bba744c55e..b770dc4b6fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -6,9 +6,9 @@ import { StructuredConversationCommandController } from './structured-conversati // only through `conversation`, which opens it at rest; an agent is started only by work that needs // it, and the idle sweep is the one thing that puts it to rest. +import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' -import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' import * as reveal from './structured-agent-session-reveal' @@ -111,17 +111,17 @@ export class StructuredAgentSessionHost { }) this.conversationDelivery = conversation.createStructuredAgentSessionConversationDelivery({ deps, + acquireAborts: this.runtimeState.acquireAborts, sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), // Quit drains a delivery start before it evicts, so the child it produces is stopped. trackStart: (start) => this.tasks.trackAttach(start), - ensureProviderChild: (sessionId, startedFor) => - agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + startProviderChild: (sessionId, startedFor) => + agentStart.claimStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), deliveryOrder: this.runtimeState.deliveryOrder, readiness: (id, child) => this.runtimeState.startupAttempts.readiness(id, child), // Put to rest like an idle agent: the stop writes nothing, and the next start is new. stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), - mutationContext: () => this.mutationContext(), clientDelivery: this.clientDelivery }) this.restore = reveal.createStructuredAgentSessionHostRestore(deps, { @@ -130,8 +130,7 @@ export class StructuredAgentSessionHost { resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), hasSession: this.hasSession, - // Site 10: cannot overwrite a live entry — the restorer returns early on - // `hasSession` inside the same serialized step as this `set`. + // The restorer returns on `hasSession` in this same step, so this never overwrites a live entry. onReadable: this.conversationDelivery.adoptOpened, onUnopened: (sessionId) => this.tabs.markUnopened(sessionId) }) @@ -161,7 +160,9 @@ export class StructuredAgentSessionHost { sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), open: (sessionId) => this.conversationDelivery.open(sessionId), - deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + // A start in flight is execution under way, even before the loop or an operation sees a child. + deliveryActive: (id) => + this.conversationDelivery.loop.isRunning(id) || this.runtimeState.startFlights.has(id), closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options), childWorkOpen: this.clientDelivery.childWorkOpen }) @@ -220,7 +221,9 @@ export class StructuredAgentSessionHost { tasks: this.tasks, lifetime: this.lifetime, sessions: this.sessions, - deliveryRunning: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + // A start registered after the stop is newer work, as a running delivery is. + deliveryRunning: (id) => + this.conversationDelivery.loop.isRunning(id) || this.runtimeState.startFlights.has(id), childWorkIncomplete: this.clientDelivery.childWorkIncomplete }, workspaceId diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index cb425bd64ad..a425922542b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -283,6 +283,23 @@ describe('the idle sweep', () => { }) }) +describe('an idle stop that fails (P2-11 a)', () => { + it('is retried on the next tick until the agent is proven gone', async () => { + await foundRestTestChat(rig) + rig.host.collaboratorsForTests().lifetime.idleSweep.dispose() + rig.adapter.closeSession.mockClear() + rig.adapter.closeSession.mockResolvedValueOnce(false) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + await sweepOnce(rig.host) + + expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + }) +}) + describe('the idle sweep with no child running (P2-22 ii)', () => { it('closes a handle whose only leftover is a prompt nobody can answer', async () => { const journal = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts index a1a99ce7183..ab7b4713f29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -1,19 +1,20 @@ -// Fake children answer the host-awaited protocol open immediately. +// Fake providers publish their generation and return protocol facts from host-awaited open. -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -export function startsWhenPublished( +type InstantStartAdapter = Omit & + Partial> + +export function startsWhenPublished( adapter: A, - _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } -): A { - let generations = 0 + _host: () => unknown +): A & StructuredAgentSessionAdapter { const acquire: StructuredAgentSessionAdapter['acquire'] = async (input) => { const acquired = await adapter.acquire(input) - const acquisitionGeneration = acquired.acquisitionGeneration ?? `instant-start-${++generations}` - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + if (!acquired.acquisitionGeneration) { + throw new Error('test provider did not publish its generation') + } + return acquired } return { ...adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index a2bdcb47139..a9f01c292f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -104,8 +104,11 @@ beforeEach(async () => { store, adapter: startsWhenPublished( { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async ({ fence }) => ({ - providerChildPhase: 'starting' as const, + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts index cd26b96e5cd..11bfd5dfded 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts @@ -23,9 +23,9 @@ let host: StructuredAgentSessionHost function modelAdapterLiveness(registerOnAcquire: boolean): void { const { acquire, dispatch } = hostTestState() const live = new Set() - const spawn = acquire.getMockImplementation()! + const acquireChild = acquire.getMockImplementation()! acquire.mockImplementation(async (input) => { - const acquisition = await spawn(input) + const acquisition = await acquireChild(input) if (registerOnAcquire) { live.add(input.identity.sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index e7a814ec048..a5b908c4a67 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -51,6 +51,9 @@ function openHost(): void { store, adapter: startsWhenPublished( { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), @@ -129,7 +132,7 @@ beforeEach(async () => { stopOwnerProcess = vi.fn() dispatch = vi.fn(async () => ({ state: 'admitted' as const })) acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { linkId: `link-${fence}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts index 0cacbda8587..8808e35e15d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts @@ -5,7 +5,10 @@ import { agentSessionRefusalError, refuse } from '../../../shared/agent-session-wire-refusals' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + createStructuredAgentSessionLogger, + neverThrowingStructuredAgentSessionLogger +} from './structured-agent-session-logger' import { STRUCTURED_AGENT_SESSION_LOG_REPEAT_WINDOW_MS as WINDOW_MS, createStructuredAgentSessionLogRepeats @@ -210,3 +213,22 @@ describe('what an Error leaves in the trace file', () => { expect(written()[0]?.attributes['errorCause']).toEqual(['Error: no space [ENOSPC]']) }) }) + +describe('a logger that throws', () => { + it('never throws into the operation it reports, and still prints what failed', () => { + const loggerError = new Error('logger broke') + const broken = () => { + throw loggerError + } + const logger = neverThrowingStructuredAgentSessionLogger({ warn: broken, error: broken }) + const error = new Error('what failed') + + expect(() => logger.warn('a step failed', { scope: 'step', error })).not.toThrow() + expect(() => logger.error('a step failed', { scope: 'step', error })).not.toThrow() + expect(console.warn).toHaveBeenCalledWith('[agent-session] a step failed', { + scope: 'step', + error, + loggerError + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts deleted file mode 100644 index 9b65150cc70..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -// The chat's own Stop and every session list read whether the main agent is working through one -// rule, but over two copies of the host's journal: the chat reduces its stream, and a list reads the -// status feed. Both leave the host from one publication edge, so once each has landed they must say -// the same thing. Against the real host, store, journal, stream and status feed. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession, - type StructuredAgentSessionState -} from '../../../shared/structured-agent-session-reducer' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } -const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let dispatch: Mock -let events: StructuredAgentSessionEventSink | undefined -/** The session's child records, as the host's store serves them to every surface. */ -let records: AgentChildWorkView[] -let chat: StructuredAgentSessionState -let listed: AgentSessionStatusSummary | undefined - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-main-agent-working-')) - resetHostTestOperationIds() - events = undefined - records = [] - chat = EMPTY_STRUCTURED_AGENT_SESSION - listed = undefined - // Written, and the provider has neither opened a turn for it nor answered it. - dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: startsWhenPublished( - { - acquire: async (input) => { - events = input.events - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: input.spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${input.fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: input.fence, - observedAt: NOW - } - } - }, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ), - statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => records }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - host.subscribe({ - id: 'chat-1', - sessionId: SESSION, - emit: (event) => { - chat = reduceStructuredAgentSession(chat, { type: 'event', event: structuredClone(event) }) - } - }) - host.subscribeStatus({ - id: 'list-1', - emit: (event) => { - if (event.type === 'status' && event.session.sessionId === SESSION) { - listed = structuredClone(event.session) - } else if (event.type === 'snapshot') { - listed = event.sessions.find((session) => session.sessionId === SESSION) ?? listed - } - } - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -async function send(text: string): Promise { - const body = hostTestMessage(text) - const clientOperationId = hostTestOperationId() - expect( - await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - ).toMatchObject({ ok: true }) - return clientOperationId -} - -async function provider(ordinal: number, body: AgentJournalItemBody): Promise { - events!.appendItem({ ...PROVIDER_ROW, ordinal }, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - events!.publish() - await host.flushStreamedEvents(SESSION) -} - -/** What the chat's Stop reads: the transport state's rule over the chat's reduced stream. */ -function chatReadsWorking(): boolean { - return isStructuredAgentSessionMainAgentWorking( - activeStructuredAgentSessionTurnId(chat.items), - chat.submissions, - chat.fence - ) -} - -/** What a session list's row reads for the main agent, and the whole row, from the status feed. */ -function listReads(): { mainAgent: string; row: string } { - const summary = listed - if (!summary?.status) { - return { mainAgent: 'none', row: 'none' } - } - const status = structuredAgentSessionAgentStatus({ - ...summary, - status: summary.status, - childWork: summary.children - }) - return { mainAgent: status.mainAgent.state, row: status.state } -} - -describe('the main agent read by the chat and by a session list', () => { - it('agrees while a rate-limited request retries with no turn open', async () => { - const id = await send('hello') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() - ) - // What Claude writes for an HTTP 429 retry: a status row, no echo, no turn. - await provider(1, { kind: 'status', text: 'rate_limit', tone: 'error' }) - await provider(2, { kind: 'status', text: 'rate_limit', tone: 'error' }) - - expect(chat.submissions.map((submission) => submission.clientMessageId)).toEqual([id]) - expect(activeStructuredAgentSessionTurnId(chat.items)).toBeNull() - expect(chatReadsWorking()).toBe(true) - expect(listReads().mainAgent).toBe('working') - }) - - it('agrees once the handed-over message is answered and only a subagent still runs', async () => { - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } - }) - await send('fan out') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') - ) - await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) - expect(chatReadsWorking()).toBe(true) - expect(listReads().mainAgent).toBe('working') - - await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' }) - records = [ - { - id: 'child-1', - providerId: 'task-1', - kind: 'agent', - name: 'deep_review', - state: 'working', - membership: 'live', - firstObservedAt: NOW, - observedAt: NOW, - stoppable: false, - invocation: { invocationId: 'task-1', generation: 1 } - } - ] - host.publishChildWorkEvidence(SESSION, []) - await host.flushStreamedEvents(SESSION) - - expect(chat.backgroundTasks?.children).toHaveLength(1) - // The row reads Working for the subagent; the main agent, and so Stop, does not. - expect(listReads()).toEqual({ mainAgent: 'done', row: 'working' }) - expect(chatReadsWorking()).toBe(false) - }) - - it('agrees when the child that was handed the message exits', async () => { - await send('hello') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() - ) - expect(chatReadsWorking()).toBe(true) - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reason: 'codex app-server crashed', - cause: 'unexpected-exit' - }) - await host.flushStreamedEvents(SESSION) - - expect(chat.fence).toBe(store.getRecord(SESSION)!.lease.runtimeFence) - expect(chatReadsWorking()).toBe(false) - expect(listReads().mainAgent).not.toBe('working') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts new file mode 100644 index 00000000000..03050db82ed --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { providerDiagnostic, withProviderDiagnostic } from '../../../shared/agent-session-failure' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function pickModel(model: string): Promise { + const fields = { key: 'model', value: model } + const result = await rig.host.setOption(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()), + ...fields + }) + expect(result).toMatchObject({ ok: true }) +} + +it('keeps the first accepted choice through opening and saves a later pick for the next send', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + const first = rig.send('first', undefined, { carried: { model: 'model-a' } }) + expect(await first.result).toMatchObject({ ok: true }) + await pickModel('model-b') + const second = rig.send('second') + expect(await second.result).toMatchObject({ ok: true }) + expect(rig.setOption).not.toHaveBeenCalled() + await rig.proveStart() + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + expect(rig.dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ model: 'model-a' }) + await rig.settleAccepted(first.id, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls[1]?.[0].executionOptions).toEqual({ model: 'model-b' }) + expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') +}) + +it('rejects a refused choice before prompt input and leaves the next pick saved', async () => { + await pickModel('model-b') + rig.dispatch.mockRejectedValueOnce( + withProviderDiagnostic( + new AgentSessionOptionRejectedError('model-a is unavailable'), + providerDiagnostic('model-a is unavailable', 'person') + ) + ) + const first = rig.send('first', undefined, { carried: { model: 'model-a' } }) + expect(await first.result).toMatchObject({ ok: true }) + await eventually(async () => + expect(await rig.submission(first.id)).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringContaining('model-a is unavailable') + }) + ) + expect(rig.dispatch).toHaveBeenCalledOnce() + expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') +}) + +it('uses each send’s captured choices when two messages arrive during startup', async () => { + const releaseStart = rig.holdNextStart() + const first = rig.send('first', undefined, { + carried: { model: 'model-a', effort: 'high' } + }) + const second = rig.send('second', undefined, { + carried: { model: 'model-b', effort: 'low' } + }) + expect(await first.result).toMatchObject({ ok: true }) + expect(await second.result).toMatchObject({ ok: true }) + expect(rig.dispatch).not.toHaveBeenCalled() + releaseStart() + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + await rig.settleAccepted(first.id, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls.map(([input]) => input.executionOptions)).toEqual([ + { model: 'model-a', effort: 'high' }, + { model: 'model-b', effort: 'low' } + ]) + expect(rig.setOption).not.toHaveBeenCalled() +}) + +it('keeps a draft’s accepted choices through a host restart and Send now', async () => { + const working = await rig.workingSend() + const choices = { model: 'model-b', effort: 'low' } + const queued = await rig.send('use these choices', 'queue-if-active', { + carried: choices + }).result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await rig.restartHostProcess() + expect(await rig.host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ + ok: true + }) + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + expect(journal?.queuedMessages.get(draftId)?.options).toEqual(choices) + await rig.settleAccepted(working, 'a') + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) + expect((await rig.handoff(draftId))?.options).toEqual(choices) +}) + +it('steers a matching choice but holds an empty default choice for a new turn', async () => { + const choices = { model: 'model-a', effort: 'high' } + const first = rig.send('first', undefined, { carried: choices }) + await first.result + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + await rig.settleAccepted(first.id, 'a') + rig + .providerEvents() + .appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-a', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + + const extras = { ...choices, personality: 'friendly', serviceTier: 'priority', fastMode: 'true' } + const matching = rig.send('same choices', undefined, { carried: extras }) + + await matching.result + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls[1]?.[0].executionOptions).toEqual(extras) + await rig.host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: matching.id, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 1 } + }) + const defaults = rig.send('default choices', undefined, { carried: {} }) + await defaults.result + expect(rig.dispatch).toHaveBeenCalledTimes(2) + + rig + .providerEvents() + .appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-a', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(3)) + expect(rig.dispatch.mock.calls[2]?.[0].executionOptions).toEqual({}) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index a77502be02a..f52e3fd7116 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -1,81 +1,38 @@ -// A message's model and effort, applied to the turn it starts the way a picker change is applied. +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { AgentSessionOptionResult } from '../../../shared/agent-session-wire' -import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' -import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' -import { performSetOption } from './structured-agent-session-turns-options' -import type { TurnOutcome } from './structured-agent-session-turns' +const OPTION_ORDER = ['model', 'effort'] as const -/** With no child running, intent the next start replays; else the running child's own write. Either - * is recorded on the chat at `fence` and published. */ -export async function writeStructuredAgentSessionOption( - context: StructuredAgentSessionMutationContext, - target: { sessionId: string; fence: number }, - input: { key: string; value: string } -): Promise> { - const { sessionId, fence } = target - const session = context.sessions.get(sessionId) - const ctx = { - sessionId, - fence, - now: () => context.now(), - adapter: context.deps.adapter, - persistOptions: (options: Readonly>) => - context.deps.store - .replaceSessionOptions({ sessionId, fence, options, now: context.now() }) - .then(() => undefined), - publish: () => session && context.publish(sessionId, session.journal) - } - const child = session?.child - if ( - input.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || - !child || - child.phase === 'starting' || - child.close !== undefined - ) { - return recordStructuredAgentSessionOptionIntent(context.deps, ctx, input) - } - // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. - const wait = context.acquireAborts.begin(sessionId) - try { - return await performSetOption(ctx, input, wait.signal) - } finally { - wait.end() +function sameOptions( + a: AgentSessionExecutionOptions | undefined, + b: AgentSessionExecutionOptions | undefined +): boolean { + if ((a === undefined) !== (b === undefined)) { + return false } + return OPTION_ORDER.every((key) => a?.[key] === b?.[key]) } -/** A message's model and effort, applied as it is handed over or its agent starts for it: model - * first, so the effort lands under it, and a value the chat already holds writes nothing. A failed - * write is logged and never stops the message, which runs on what the chat holds. */ -export async function applyStructuredAgentSessionMessageOptions( - context: StructuredAgentSessionMutationContext, - sessionId: string, - options: Readonly> | undefined -): Promise { - const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence - if (!options || fence === undefined) { - return +/** A different Codex model or effort needs a new turn: steer has no choice parameters. */ +export function waitsForCodexTurnOptions( + journal: Pick, + next: AgentJournalSubmission +): boolean { + const body = journal.itemBody(agentJournalSubmissionKey(next.clientMessageId)) + if (body?.kind !== 'message' || body.command) { + return false } - const entries = Object.entries(options).sort( - ([a], [b]) => Number(b === 'model') - Number(a === 'model') - ) - for (const [key, value] of entries) { - if (context.deps.store.getRecord(sessionId)?.options?.[key] === value) { - continue - } - const written = await writeStructuredAgentSessionOption( - context, - { sessionId, fence }, - { key, value } - ).catch((error: unknown) => ({ ok: false as const, error })) - if (!written.ok) { - context.deps.logger.warn("applying a message's option failed", { - scope: 'message-options', - sessionId, - key, - error: 'error' in written ? written.error : written.refusal - }) - } + const turn = journal.newestTurn() + if (turn?.state !== 'running') { + return false } + const active = journal.submissions().find((submission) => { + if (submission.handedOverAt === undefined) { + return false + } + return agentJournalSubmissionKey(submission.clientMessageId) === turn.userItemId + }) + return active ? !sameOptions(active.options, next.options) : true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts index 6422b4b8437..ff51888df85 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts @@ -214,7 +214,7 @@ it.each([false, true])( } })) } - const ensureAgent = vi.spyOn(ctx, 'ensureAgent') + const startAgent = vi.spyOn(ctx, 'claimAgent') const error = new Error('receipt read failed') const warn = vi.spyOn(ctx.deps.logger, 'warn') vi.spyOn(rig.store, 'readCommandReceipt').mockImplementationOnce(() => { @@ -234,7 +234,7 @@ it.each([false, true])( ) expect(rig.host['sessions'].get(SESSION)?.journal.submissions()).toHaveLength(0) expect(ctx.wakeDelivery).not.toHaveBeenCalled() - expect(ensureAgent).not.toHaveBeenCalled() + expect(startAgent).not.toHaveBeenCalled() } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 9c34cdb68fc..4b7d40382e0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -17,6 +17,8 @@ import type { StructuredAgentSessionStopEnding } from './structured-agent-sessio import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import type { CapturedChildWorkStops } from './structured-agent-session-status-ownership' +import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import type { @@ -41,8 +43,9 @@ export type StructuredAgentSessionMutationContext = { serialize: (sessionId: string, task: () => Promise) => Promise /** The session's conversation, opened when closed; inside the caller's serialize. */ openConversation: (sessionId: string) => Promise - /** Gives the session a provider child; inside the caller's serialize. */ - ensureAgent: (sessionId: string) => Promise + /** Claims a provider start on the caller's lane; its outcome is awaited outside. */ + claimAgent: (sessionId: string) => StructuredAgentSessionStartClaim + startFlights: Pick startupAttempts: Pick deliveryOrder: StructuredAgentSessionDeliveryOrder /** Joins a close a stop began on the session's child, for an operation that starts no child; @@ -59,7 +62,7 @@ export type StructuredAgentSessionMutationContext = { wakeQueuedDrain?: (sessionId: string) => void /** The provider wait each session's serialize is on (a start, an option write), which a caller * outside that serialize aborts. */ - acquireAborts: Pick + acquireAborts: Pick /** Moved by every pick a running child takes, so a report it read before is never persisted. */ now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 9664ca0389b..b13e3aa3853 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -6,13 +6,13 @@ // others' current answer. import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { AGENT_MESSAGE_SOURCE, USER_MESSAGE_SOURCE, type AgentSessionMessageSource } from '../../../shared/agent-session-message-source' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionCancelResult, @@ -37,7 +37,6 @@ import { import { performPrompt, performSend, - performSetOption, type AgentSessionTurnContext, type TurnOutcome } from './structured-agent-session-turns' @@ -158,7 +157,7 @@ export function sendPlan( recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. - run: (ctx) => { + run: async (ctx) => { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() const source = sendSource(params) @@ -336,9 +335,13 @@ export function setOptionPlan(params: { return { method: 'agentSession.setOption', fields: { key: params.key, value: params.value }, - run: (ctx) => performSetOption(ctx, params), - // A pending row may have crashed before the adapter call. Reapplying the - // same assignment is safe; only a settled success can be answered directly. + run: async (ctx) => { + const options = { ...ctx.persistedOptions, [params.key]: params.value } + await ctx.persistOptions(options) + ctx.publish() + return { ok: true, value: { ...params, options } } + }, + // Reapply unsettled picks; only a settled success can be replayed directly. replay: (ctx, outcome) => outcome.status === 'succeeded' ? { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts new file mode 100644 index 00000000000..b90a476149f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts @@ -0,0 +1,95 @@ +// The child an operation that needs the provider runs on, and the refusal of one that did not run. + +import { refuse, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' +import type { + AgentSessionMutationEnvelope, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { sameProviderChild } from './structured-agent-session-provider-child' +import { openForProviderWrite } from './structured-agent-session-send-preparation' +import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' +import type { AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' +import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' + +export type ReadyOperationChild = StructuredAgentSessionProviderChildIdentity | null +export type ReadyOperationSelection = { + ok: true + child: ReadyOperationChild + replay?: true + signal?: AbortSignal + deadline?: number +} + +export function operationDidNotRun(expired = false): { + ok: false + refusal: AgentSessionWireRefusal +} { + return { + ok: false, + refusal: expired + ? refuse( + 'agent_session_operation_expired', + { reason: 'operationExpired' }, + 'The operation did not run because its wait expired. Try again.' + ) + : refuseUnclassified( + 'agent_session_owner_restart_failed', + 'The operation did not run because the agent is not ready.' + ) + } +} + +/** The child an operation runs on, and its readiness while it starts; a null child means none is + * there to run on, and one must start first. */ +export function selectStructuredAgentSessionOperationChild( + context: StructuredAgentSessionMutationContext, + sessionId: string +): + | { ok: false; refusal: AgentSessionWireRefusal } + | { + ok: true + child: StructuredAgentSessionProviderChildIdentity | null + readiness: StructuredAgentSessionStartReadiness | null + } { + const child = context.sessions.get(sessionId)?.child + if (!child || child.close) { + return { ok: true, child: null, readiness: null } + } + const identity = { generation: child.generation, fence: child.fence } + if (child.phase === 'ready') { + return { ok: true, child: identity, readiness: null } + } + const readiness = context.startupAttempts.readiness(sessionId, identity) + return readiness ? { ok: true, child: identity, readiness } : operationDidNotRun() +} + +/** Replay uses the ledger answer; a new operation uses only its selected ready child. */ +export function openWithReadyStructuredAgentSessionChild( + context: StructuredAgentSessionMutationContext, + envelope: AgentSessionMutationEnvelope, + selection: ReadyOperationSelection +): (ledger: 'admit' | 'replay') => Promise { + return async (ledger) => { + const opened = await openForProviderWrite(context, envelope)() + if (!opened.ok || ledger === 'replay') { + return opened + } + if (selection.signal?.aborted) { + return operationDidNotRun() + } + if (!selection.child) { + return selection.replay + ? { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } + : operationDidNotRun() + } + if (selection.deadline !== undefined && Date.now() >= selection.deadline) { + return operationDidNotRun(true) + } + const child = context.sessions.get(envelope.sessionId)?.child + return child?.phase === 'ready' && !child.close && sameProviderChild(child, selection.child) + ? opened + : operationDidNotRun() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index 6a2e1c55f08..33210674779 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -4,7 +4,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' +import { startStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' @@ -18,11 +20,18 @@ describe('an operation whose agent start throws', () => { const context = { deps: { logger: log.logger }, sessions: new Map(), + serialize: (_sessionId: string, task: () => Promise) => task(), + tasks: { trackAttach: (start: Promise) => start }, + runtimeState: { + acquireAborts: new StructuredAgentSessionAcquireAborts(), + startFlights: new StructuredAgentSessionStartFlights(), + startupAttempts: { begin: () => {}, abandon: () => {} } + }, reconcileLeases: () => Promise.reject(cause) } - const refused = await ensureStructuredAgentSessionAgentForOperation( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any other member is read. + const refused = await startStructuredAgentSessionAgentForOperation( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any member past its registration is read. context as unknown as StructuredAgentSessionAttachContext, SESSION ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts deleted file mode 100644 index b1ec8d997a9..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { encodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' - -export async function readNativeSessionOptions(input: { - adapter: Pick - sessionId: string - fence: number - priorOptions?: Readonly> -}): Promise> | undefined> { - const { adapter, sessionId, fence, priorOptions } = input - const reported = await adapter.readOptions?.({ sessionId, fence }) - if (!reported) { - return undefined - } - return nativeSessionOptionsFromReport({ - reported: reported.current, - restoreSkipped: adapter.readOptionRestoreFailures?.(sessionId) ?? [], - ...(priorOptions ? { priorOptions } : {}) - }) -} - -/** The record's options once the provider has reported: its model, effort, Fast and service tier - * replace the saved ones, other saved options stay, and any the child could not take are dropped. */ -export function nativeSessionOptionsFromReport(input: { - reported: AgentSessionOptionsResult['current'] - restoreSkipped: readonly string[] - priorOptions?: Readonly> - /** Values the child showed it cannot run: its report of the same value is not kept. */ - retired?: Readonly> -}): Readonly> { - const { reported, priorOptions } = input - const restored = priorOptions ? { ...priorOptions } : {} - delete restored.model - delete restored.effort - delete restored.fastMode - delete restored.serviceTier - for (const key of input.restoreSkipped) { - delete restored[key] - } - // A reported tier is the speed itself; the Fast it implies for older clients is not kept. - const fastMode = - reported.fastMode === undefined || reported.serviceTier !== undefined - ? undefined - : encodeStructuredAgentSessionOptionValue('fastMode', reported.fastMode) - const options: Record = { - ...restored, - ...(reported.model ? { model: reported.model } : {}), - ...(reported.effort ? { effort: reported.effort } : {}), - ...(fastMode !== undefined && fastMode !== null ? { fastMode } : {}), - ...(reported.serviceTier ? { serviceTier: reported.serviceTier } : {}) - } - for (const [key, value] of Object.entries(input.retired ?? {})) { - if (options[key] === value) { - delete options[key] - } - } - return options -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts deleted file mode 100644 index 1876416e7d5..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionMutationEnvelope, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } -const DEFAULT_MODEL = 'gpt-default' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let router: StructuredAgentSessionAdapter -let acquire: Mock -let closeNativeSession: Mock> -let activeModel: string -let activeEffort: string | null -let optionFailure: Error | null -/** What the adapter's closeSession reports about the child's exit. */ -let closeSessionExit = true -const dispatchedModels: string[] = [] - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function adapter(): StructuredAgentSessionAdapter { - acquire = vi.fn(async ({ fence, spawnToken, options, identity }) => { - activeModel = options?.model ?? DEFAULT_MODEL - activeEffort = options?.effort ?? null - const acquisitionGeneration = `generation-${acquire.mock.calls.length}` - // The handshake answers at once, reporting the options the child runs with. - void host.handleAdapterEvent({ - type: 'started', - sessionId: identity.sessionId, - fence, - acquisitionGeneration, - reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } - }) - return { - providerChildPhase: 'starting' as const, - acquisitionGeneration, - process: { - hostId: 'local', - pid: 4200 + acquire.mock.calls.length, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `native-link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - closeNativeSession = vi.fn(async () => { - activeModel = DEFAULT_MODEL - return closeSessionExit - }) - return { - supportsLocation: () => true, - acquire, - dispatch: vi.fn(async () => { - dispatchedModels.push(activeModel) - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - } - }), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async ({ key, value }) => { - if (optionFailure) { - const error = optionFailure - optionFailure = null - throw error - } - if (key === 'model') { - activeModel = value - } else if (key === 'effort') { - activeEffort = value - } - return { - model: activeModel, - ...(activeEffort ? { effort: activeEffort } : {}) - } - }), - readOptions: vi.fn(async () => ({ - current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, - models: [] - })), - closeSession: closeNativeSession - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-option-settlement-')) - resetHostTestOperationIds() - activeModel = DEFAULT_MODEL - activeEffort = null - optionFailure = null - closeSessionExit = true - dispatchedModels.length = 0 - const accountHome = join(root, 'codex-home') - store = await openTestAgentSessionRecordStore(root) - router = adapter() - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: router, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-native', - now: () => NOW - }) - const attached = await host.attach( - CALLER, - hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) - ) - expect(attached).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('structured session options and close', () => { - it('keeps provider readback out of the saved next-message choice', async () => { - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'first task' }] - } - await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - // Accepted, then handed over by the delivery loop; the status is read once it answered. - await vi.waitFor(() => expect(dispatchedModels).toEqual([DEFAULT_MODEL])) - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') - ) - const events: AgentSessionStatusEvent[] = [] - host.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - expect(events).toEqual([ - { - type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle' })] - } - ]) - expect(events[0]).not.toHaveProperty('sessions.0.model') - expect(store.getRecord(SESSION)?.options).toBeUndefined() - }) - - // Closing a chat used to leave its provider child resident for the whole app session: the host's - // session map had no delete and the only teardown was app quit. - it('stops the provider child and forgets the session when the chat closes', async () => { - expect(host.hasSession(SESSION)).toBe(true) - - await host.close(SESSION, 'evict') - - expect(closeNativeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { kind: 'exit-observed' } - }) - expect(host.hasSession(SESSION)).toBe(false) - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeNativeSession).toHaveBeenCalledOnce() - }) - - it('is a no-op for a session it does not hold', async () => { - await host.close(SESSION, 'evict') - closeNativeSession.mockClear() - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeNativeSession).not.toHaveBeenCalled() - }) -}) - -it('applies running Codex model and effort picks to the next message', async () => { - await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) - for (const [key, value] of [ - ['model', 'gpt-picked'], - ['effort', 'high'] - ]) { - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key, value }), - key, - value - }) - ).toMatchObject({ ok: true }) - } - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'with the new choices' }] - } - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(dispatchedModels).toEqual(['gpt-picked'])) - expect(activeEffort).toBe('high') -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 670d6903543..ac6bde0907c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -181,8 +181,8 @@ describe('live Codex option reads', () => { it('keeps newer saved picks after a cold live listing waits without holding the session lane', async () => { const listing = Promise.withResolvers() const queue = new StructuredAgentSessionTaskQueue() + const record = { ...restingRecord(), options: { model: 'gpt-picked', effort: 'high' } } const child = { fence: 7 } - const record = restingRecord() const apply = vi.fn(() => ({ models: [{ id: 'gpt-live', label: 'GPT Live', efforts: [] }], current: { model: 'gpt-live', effort: 'high', confirmed: ['model', 'effort'] as const } @@ -209,7 +209,9 @@ describe('live Codex option reads', () => { conversation: async () => live } as unknown as StructuredAgentSessionMutationContext - const reading = readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + const reading = readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: false + }) await vi.waitFor(() => expect(prepareReadOptions).toHaveBeenCalledWith({ sessionId: SESSION, fence: 7 }) ) @@ -220,7 +222,7 @@ describe('live Codex option reads', () => { record.options = { model: 'gpt-next', effort: 'low' } listing.resolve() await expect(reading).resolves.toMatchObject({ - current: { model: 'gpt-next', effort: 'low', confirmed: [] } + current: { model: 'gpt-next', effort: 'low' } }) expect(apply).toHaveBeenCalledOnce() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 47bb3ed005a..add321f39ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -1,9 +1,7 @@ // A chat's options, live or at rest. // -// At rest nothing here needs a child. The pick is the record's own `options` — what the next start replays — -// and the list is the host's model catalog, the same one the picker already reads before a chat -// exists. A pick made at rest is written to the record as intent, through the same transition a live -// pick takes, so the next start applies it. +// The record keeps the next message's choices; the catalog names known defaults. A provider's +// reports remain execution facts and never replace the saved selection. import { refuse, @@ -269,28 +267,38 @@ export async function readStructuredAgentSessionOptions( const permissionMode = record ? await structuredAgentSessionLaunchPermission(context.deps, record) : null - // The saved pick shows, except one the running child could not take (a model its provider says + const selected = live ? await atRest() : options + // The saved pick shows, except a model the running child could not take (its provider says it // does not exist): that shows what the child runs instead. - const refused = live ? (adapter.readOptionRestoreFailures?.(sessionId) ?? []) : [] - const saved = record?.options - const shownSaved = (key: 'model' | 'effort'): boolean => - saved?.[key] !== undefined && !refused.includes(key) - const selected = live && (shownSaved('model') || shownSaved('effort')) ? await atRest() : options + const refusedModel = live && !!adapter.readOptionRestoreFailures?.(sessionId).includes('model') + const saved = record?.options ?? {} + const { model: _refused, ...selectedWithoutModel } = selected.current + const model = refusedModel + ? options.current.model + : (saved.model ?? (options.current.model || selected.current.model)) const current = { + ...(refusedModel ? selectedWithoutModel : selected.current), ...options.current, - ...(shownSaved('model') ? { model: selected.current.model } : {}), - ...(shownSaved('effort') ? { effort: selected.current.effort } : {}) + ...(model !== undefined ? { model } : {}), + effort: saved.effort ?? options.current.effort, + fastMode: + saved.fastMode !== undefined + ? selected.current.fastMode + : (options.current.fastMode ?? selected.current.fastMode), + serviceTier: saved.serviceTier ?? options.current.serviceTier ?? selected.current.serviceTier } - const confirmed = current.confirmed?.filter((key) => - key === 'model' - ? current.model === options.current.model - : key !== 'effort' || current.effort === options.current.effort + const confirmed = options.current.confirmed?.filter((key) => + key === 'model' || key === 'effort' || key === 'fastMode' || key === 'serviceTier' + ? current[key] === options.current[key] + : false ) return { ...options, current: { ...current, - ...(confirmed ? { confirmed } : {}), + confirmed, + ...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}), + ...(confirmed?.length ? { confirmed } : {}), ...(permissionMode ? { permissionMode } : {}) }, rewind: diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index 6ab1111089b..e163496614d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -28,7 +28,6 @@ import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' import type { JournalItemLinkageVisitor } from '../agent-session-journal/journal-store-contracts' describe('a send after the agent said it is not signed in', () => { @@ -68,17 +67,6 @@ describe('a send after the agent said it is not signed in', () => { expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) }) - // A Pi that started with no model keeps a placeholder: a sign-in made since reaches a new Pi only. - it('goes to a new agent when the running one started signed out', async () => { - await foundRestTestChat(rig) - rig.adapter.startUnavailable.mockReturnValueOnce({ reason: 'notSignedIn' }) - rig.adapter.closeSession.mockClear() - expect((await rig.host.send(CALLER, restTestSend('signed in since', fence()))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) - }) - it('keeps the agent after any other rejection', async () => { await foundRestTestChat(rig) rig.adapter.dispatch.mockImplementationOnce(async () => ({ @@ -177,14 +165,6 @@ describe('structuredAgentSessionChildReportedSignedOut', () => { expect(reported({ items, child: childAt({ phase: 'starting' }) })).toBe(false) expect(reported({ items, child: null })).toBe(false) }) - - it("reads the running child's own start, and only a signed-out one", () => { - const fromStart = (startUnavailable: AgentSessionUnavailable, child = childAt({})) => - structuredAgentSessionChildReportedSignedOut(conversation({ child }), startUnavailable) - expect(fromStart({ reason: 'notSignedIn' })).toBe(true) - expect(fromStart({ reason: 'cliMissing' })).toBe(false) - expect(fromStart({ reason: 'notSignedIn' }, childAt({ phase: 'starting' }))).toBe(false) - }) }) describe('retireOutgrownStructuredAgentSessionChild', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 83321c39802..8ae3c72f662 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -1,7 +1,6 @@ // A child the chat outgrew (signed out, or launched with another permission mode) is replaced before // the next send: agents read their login and permission mode only when they start. -import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' @@ -25,20 +24,15 @@ type OutgrownChildReading = Pick & { const signedOut = (failure: unknown): boolean => readWholeAgentSessionFailureFact(failure)?.kind === 'notSignedIn' -/** The running child itself said it is not signed in: its start, a send it rejected, or a row it - * wrote. Each child holds its own fence, so matching it scopes both to this child; a new one has - * nothing. `startUnavailable` is the adapter's read of that child's start. */ +/** The running child itself said it is not signed in through a rejected send or a row it wrote. + * Its fence scopes both to this child, so a new one has nothing. */ export function structuredAgentSessionChildReportedSignedOut( - session: OutgrownChildReading, - startUnavailable?: AgentSessionUnavailable + session: OutgrownChildReading ): boolean { const { child, journal } = session if (!child || child.phase === 'starting' || child.close) { return false } - if (startUnavailable?.reason === 'notSignedIn') { - return true - } if ( journal .submissions() @@ -99,7 +93,6 @@ export async function retireOutgrownStructuredAgentSessionChild( work: StructuredAgentSessionChildWorkReads & { childWork: () => readonly AgentChildWorkView[] | undefined } - startUnavailable?: () => AgentSessionUnavailable | undefined /** The mode a start of the chat would launch with now. */ launchPermission?: () => Promise stopAgent: (sessionId: string) => Promise @@ -109,7 +102,7 @@ export async function retireOutgrownStructuredAgentSessionChild( if ( !session || !( - structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) || + structuredAgentSessionChildReportedSignedOut(session) || (await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) ) || structuredAgentSessionChildHasOpenWork(deps.work) || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index f4ffba51528..21244820e41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -201,12 +201,10 @@ describe('a Claude chat whose CLI is still starting', () => { }) it('gives a message held while starting a full idle window after startup lands to open its turn', async () => { + claude = fakeClaude({ replayUuid: null }) await attachStarting() const connection = claude.connections[0] // Claude echoes a prompt only when it starts that turn, which a loaded machine delays. - connection.send = async (message) => { - connection.sent.push(message) - } const held = await send('sent while starting') expect(connection.sent).toEqual([]) expect(await dispatchState(held)).toBe('pending') @@ -234,7 +232,7 @@ describe('a Claude chat whose CLI is still starting', () => { await host.flushStreamedEvents(SESSION) await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - expect(await dispatchState(held)).toBe('accepted') + await vi.waitFor(async () => expect(await dispatchState(held)).toBe('accepted')) }) it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts deleted file mode 100644 index 169902a3875..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../../shared/claude-profile-routing' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { mapRuntimeError } from '../../runtime/rpc/errors' -import { - AgentSessionPreSpawnError, - type AgentSessionPreSpawnReason, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { StructuredAgentArgumentsError } from '../structured-agent-arguments-error' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha' -const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - vi.restoreAllMocks() - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function createParams(): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - runtimeKind: 'native' - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -/** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ -async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { - root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) - const store = await openTestAgentSessionRecordStore(root) - const unused = async (): Promise => { - throw new Error('not reached before a spawn') - } - const adapter: StructuredAgentSessionAdapter = { - acquire: async () => { - throw thrown - }, - dispatch: unused, - cancelTurn: unused, - answerPrompt: unused, - setOption: unused - } - const input = { - agents: NO_STRUCTURED_AGENTS, - store, - adapter, - logger: createStructuredAgentSessionLogger(), - journalDatabase: openTestJournalHostDatabase(root), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' as const } - }, - callerKey: 'client-1', - params: createParams(), - now: () => NOW, - onAttached: () => {} - } - const first = await performAttach(input).then( - (result) => result, - (error: unknown) => error - ) - if (first instanceof Error) { - expect(first).toBeInstanceOf(AgentSessionPreSpawnError) - } - return { - first: - first instanceof Error ? mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first) : first, - replay: await performAttach(input) - } -} - -describe('a create that fails before any process spawns', () => { - it('answers a safe saved Arguments problem on the first call and replay', async () => { - const { first, replay } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError( - new StructuredAgentArgumentsError('Claude', '--model=private', 'multipleValues') - ) - ) - const sentence = - "Claude couldn't start. Saved Arguments give --model more than one value. Edit them in Settings > Agents > Arguments. Send your message to try again." - for (const result of [first, replay]) { - expect(result).toMatchObject({ - ok: false, - refusal: { - message: sentence, - details: { - reason: 'attachFailed', - argumentProblem: { agent: 'Claude', option: '--model', problem: 'multipleValues' } - } - } - }) - } - expect(JSON.stringify([first, replay])).not.toContain('private') - }) - - it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ - [ - 'the managed account env override', - 'This Claude launch defines explicit Anthropic auth environment variables.', - 'managedAccountEnvOverride', - 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' - ], - [ - 'an account switch in progress', - 'A Claude account switch is in progress. Try again after it finishes.', - 'accountSwitchInProgress', - "Claude's setup is changing on this host. Try again after it finishes." - ], - [ - 'a Claude account added in WSL', - 'structured Claude is not offered under the active managed Claude account', - 'managedAccountUnsupported', - 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.' - ], - [ - 'a floating chat whose launch folder is gone', - 'the folder this chat ran in no longer exists: /gone/floating', - 'launchFolderMissing', - 'The folder this chat ran in no longer exists. Restore it to continue this chat.' - ], - [ - 'a resume whose transcript is in another Claude account', - 'claude transcript is not in the selected account', - 'historyInOtherAccount', - "This chat's history is in another Claude account. Switch back to that account to continue it." - ], - [ - 'a selected Claude account whose folder is gone', - CLAUDE_PROFILE_MISSING_MESSAGE, - 'claudeAccountFolderMissing', - CLAUDE_PROFILE_MISSING_MESSAGE - ], - [ - 'a selected Claude account that could not be set up', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - 'claudeAccountSetupFailed', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ], - [ - 'a Command setting that names no runnable program', - 'the claude Command setting is not a runnable program', - 'agentCommandNotRunnable', - "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." - ], - [ - "Orca's own reason", - 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', - undefined, - "Claude couldn't start. Send your message to try again." - ] - ])('answers %s first in the words its replay reads', async (_, raw, reason, sentence) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - const { first, replay } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError(new Error(raw), reason ? { reason } : {}) - ) - - // Thrown as before: the wire code is unchanged, only the words are the replay's. - expect(first).toMatchObject({ ok: false, error: { code: 'runtime_error', message: sentence } }) - expect(replay).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: sentence, - ...(reason ? { details: { reason } } : {}) - } - }) - if (!reason) { - expect(replay).not.toHaveProperty('refusal.details') - } - if (raw !== sentence) { - expect(JSON.stringify([first, replay])).not.toContain(raw) - } - // What failed is kept for the log. - expect(warn).toHaveBeenCalledWith( - '[agent-session] provider-start: starting the provider failed', - expect.objectContaining({ - scope: 'provider-start', - error: expect.objectContaining({ message: raw }) - }) - ) - }) - - it('keeps a message that is itself a code, and the wire code it maps to', async () => { - const { first } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError(new Error('runtime_unavailable')) - ) - - expect(first).toMatchObject({ - ok: false, - error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } - }) - }) - - it('keeps the situation of a pre-spawn error it wraps', () => { - const typed = new AgentSessionPreSpawnError(new Error('switching'), { - reason: 'accountSwitchInProgress' - }) - - expect(new AgentSessionPreSpawnError(typed).reason).toBe('accountSwitchInProgress') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 543abc1e253..3e76030579a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -68,6 +68,21 @@ function attachParams( } } +function adapterForAcquire( + acquire: StructuredAgentSessionAdapter['acquire'] +): StructuredAgentSessionAdapter { + return { + open: async () => ({ + reportedOptions: { model: '' } + }), + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + describe('processless structured session reservation', () => { it('refuses an adapter that declares no create support before reserving a lease', async () => { root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) @@ -75,13 +90,9 @@ describe('processless structured session reservation', () => { const reserveOwner = vi.spyOn(store, 'reserveOwner') const acquire = vi.fn() const adapter = { - supportsCreate: vi.fn(() => false), - acquire, - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } as unknown as StructuredAgentSessionAdapter + ...adapterForAcquire(acquire), + supportsCreate: vi.fn(() => false) + } await expect( performAttach({ @@ -117,10 +128,11 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(true) .mockReturnValueOnce(false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only `supportsCreate` and `acquire` on this fake adapter. const adapter = { + ...adapterForAcquire(vi.fn()), supportsCreate, acquire: vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'link-1', @@ -130,7 +142,7 @@ describe('processless structured session reservation', () => { observedAt: NOW } })) - } as unknown as StructuredAgentSessionAdapter + } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -169,7 +181,7 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(true) const acquire = vi.fn() - const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter + const adapter = { ...adapterForAcquire(acquire), supportsCreate } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -216,11 +228,11 @@ describe('processless structured session reservation', () => { it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) const store = await openTestAgentSessionRecordStore(root) - const adapter = { - acquire: vi.fn(async () => { + const adapter = adapterForAcquire( + vi.fn(async () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) }) - } as unknown as StructuredAgentSessionAdapter + ) const settlement = vi.spyOn(store, 'settleFailedAcquisition') await expect( @@ -270,12 +282,13 @@ describe('processless structured session reservation', () => { it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) const store = await openTestAgentSessionRecordStore(root) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only the members this fake adapter defines. const adapter = { + ...adapterForAcquire(vi.fn()), acquire: vi .fn() .mockRejectedValueOnce(new AgentSessionPreSpawnError(new Error('launch not ready'))) .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -291,7 +304,7 @@ describe('processless structured session reservation', () => { } })), releaseAcquisition: vi.fn(async () => true) - } as unknown as StructuredAgentSessionAdapter + } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts index a5bc09e798b..8a6cd9ecc6e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts @@ -9,6 +9,7 @@ import { type AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestJournalHostDatabase, @@ -132,6 +133,40 @@ function failReceiptWrites(): () => void { return () => db.exec('DROP TRIGGER fail_prompt_receipt') } +const GROUPED_ANSWERS = [ + { questionId: 'q1', optionIds: ['web', 'mobile'] }, + { questionId: 'q2', optionIds: [], other: 'SSH host' } +] + +/** A provider question with a multi-select and a free-text part; resolves its item id. */ +async function seedGroupedQuestion(): Promise { + const question = { ...PROMPT, ordinal: 100 } + acquire.mock.calls.at(-1)?.[0].events?.appendItem( + question, + { + kind: 'question', + question: '2 grouped questions', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'web', label: 'Web' }, + { id: 'mobile', label: 'Mobile' } + ] + }, + { id: 'q2', question: 'Host', multiSelect: false, options: [], freeTextQuestionId: 'q2' } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + return agentJournalItemKey(question) +} + async function resolution(itemId: string) { const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const item = page.ok ? page.page.items.find((entry) => entry.itemId === itemId) : undefined @@ -319,43 +354,19 @@ describe('a prompt answer', () => { it('replays a grouped answer whole after a rewind, free text included', async () => { await attach() - const question = { ...PROMPT, ordinal: 100 } - acquire.mock.calls.at(-1)?.[0].events?.appendItem( - question, - { - kind: 'question', - question: '2 grouped questions', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'web', label: 'Web' }, - { id: 'mobile', label: 'Mobile' } - ] - }, - { id: 'q2', question: 'Host', multiSelect: false, options: [], freeTextQuestionId: 'q2' } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) - const itemId = agentJournalItemKey(question) - const answers = [ - { questionId: 'q1', optionIds: ['web', 'mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - const fields = { itemId, expectedRevision: journal().item(itemId)!.revision, answers } + const itemId = await seedGroupedQuestion() + const fields = { + itemId, + expectedRevision: journal().item(itemId)!.revision, + answers: GROUPED_ANSWERS + } const params = { envelope: envelope('agentSession.respondTo:question', fields, { clientOperationId: opId() }), kind: 'question' as const, ...fields } const first = await host.respondToPrompt(CALLER, params) - expect(first).toMatchObject({ ok: true, value: { resolution: { answers } } }) + expect(first).toMatchObject({ ok: true, value: { resolution: { answers: GROUPED_ANSWERS } } }) await journal().replaceEpochItems( 'handle_forked', store.getRecord(SESSION)!.lease.runtimeFence, @@ -369,6 +380,26 @@ describe('a prompt answer', () => { expect(answerPrompt).toHaveBeenCalledOnce() }) + it('unpacks the grouped answer an older client packs into its option id', async () => { + await attach() + const itemId = await seedGroupedQuestion() + const optionId = encodeAgentSessionQuestionAnswers(GROUPED_ANSWERS) + const fields = { itemId, expectedRevision: journal().item(itemId)!.revision, optionId } + + expect( + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields, { + clientOperationId: opId() + }), + kind: 'question', + ...fields + }) + ).toMatchObject({ ok: true, value: { resolution: { answers: GROUPED_ANSWERS } } }) + expect(answerPrompt).toHaveBeenCalledWith( + expect.objectContaining({ itemId, response: { kind: 'answers', answers: GROUPED_ANSWERS } }) + ) + }) + it('answers unknown, never success, when acknowledging an answer cannot be recorded', async () => { const prompt = await seeded() await host.respondToPrompt(CALLER, answer(prompt, opId())) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts deleted file mode 100644 index c8914cf56a7..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ /dev/null @@ -1,909 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// The provider child is its own record on the conversation: stopping it, losing it or failing to -// start it ends the child, never the conversation. Against the real host, store and journal, with a -// live subscriber opened before each action. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionStatusSummary, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' -import { - agentSessionFailureFact, - type AgentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredQueuePauses } from './structured-agent-session-queued-pause' -import { - HOST_TEST_LOCATION, - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' - -const CALLER = { callerKey: 'client-1' } -const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { - surface: 'rejection' -}) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let dispatch: Mock -let adapterExtras: Partial - -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -function generation(): string { - return `generation-${acquire.mock.calls.length}` -} - -const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: generation(), - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } -}) - -const starts = structuredAgentSessionHeldStarts(() => host) - -/** A child whose handshake is still running: `starting` until the test's `prove()`. */ -const spawnStartingChild = starts.held(spawnChild) - -function startHost(): void { - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: starts.wrap({ - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), - ...adapterExtras, - acquire - }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW - }) -} - -async function restartHost(): Promise { - await host.flushAllStreamedEvents() - startHost() -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-child-record-')) - resetHostTestOperationIds() - adapterExtras = {} - starts.clear() - acquire = vi.fn(spawnChild) - dispatch = vi.fn(async (input) => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${input.clientMessageId}`, - ordinal: dispatch.mock.calls.length - } - })) - store = await openTestAgentSessionRecordStore(root) - startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - await host.close(SESSION, 'evict') -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function sendParams(text: string) { - const body = hostTestMessage(text) - return { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - } -} - -async function accept(text: string, options: { person?: true } = {}): Promise { - const params = sendParams(text) - const sent = await host.send(CALLER, { ...params, ...(options.person ? { userSend: true } : {}) }) - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - return params.envelope.clientOperationId -} - -function stop() { - const turnId = 'turn-none' - return host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields: { turnId } - }) - }, - turnId - }) -} - -async function submission(id: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === id - ) -} - -async function statusRows(): Promise< - { - itemId: string - text: string - tone?: string - failure?: AgentSessionFailureFact - }[] -> { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' - ? [ - { - itemId: item.itemId, - text: item.body.text, - ...(item.body.tone ? { tone: item.body.tone } : {}), - ...(item.body.failure ? { failure: item.body.failure } : {}) - } - ] - : [] - ) -} - -/** The child the conversation has now, as its lifecycle events name it. */ -function currentChild() { - const child = conversation()?.child - if (!child?.generation) { - throw new Error('no child indexed') - } - return { sessionId: SESSION, fence: child.fence, acquisitionGeneration: child.generation } -} - -/** The current child proves its start, as a publish-first provider's `started` event does. */ -function prove(): Promise { - const { acquisitionGeneration, ...child } = currentChild() - return host.handleAdapterEvent({ - type: 'started', - ...child, - acquisitionGeneration, - reportedOptions: { model: 'default' } - }) -} - -function exit(child: ReturnType, reason: string, startupUnproven?: true) { - return host.handleAdapterEvent({ - type: 'ended', - ...child, - reason, - // As an adapter reports it: the exit's stderr as a log detail. - failure: { kind: 'providerExited', detail: { text: reason, audience: 'log' } }, - cause: 'unexpected-exit', - ...(startupUnproven ? { startupUnproven } : {}) - }) -} - -function rejectedIn(events: AgentSessionSubscribeEvent[], id: string): boolean { - return events.some( - (event) => - event.type === 'batch' && - event.batch.submissions.some( - (entry) => entry.clientMessageId === id && entry.dispatchState === 'rejected' - ) - ) -} - -function conversation() { - return host['sessions'].get(SESSION) -} - -async function subscribe(): Promise { - const events: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ - id: 'sub-1', - sessionId: SESSION, - emit: (event) => events.push(structuredClone(event)) - }) - return events -} - -/** The chat's status row as a session list sees it, frame by frame. */ -function watchStatus(): AgentSessionStatusSummary[] { - const frames: AgentSessionStatusSummary[] = [] - host.subscribeStatus({ - id: 'list-1', - emit: (event) => { - if (event.type === 'status' && event.session.sessionId === SESSION) { - frames.push(event.session) - } - } - }) - return frames -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - -describe('Stop on a child still proving its start', () => { - it('ends the child and keeps the conversation and its readers (R1)', async () => { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - // Held for the starting child, which never proves its start. - const first = await accept('hello') - const journal = conversation()?.journal - const events = await subscribe() - const frames = watchStatus() - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - expect((await submission(first))?.handedOverAt).toBeUndefined() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - // The same conversation: no reopen, and the chat told it is idle again. - expect(conversation()?.journal).toBe(journal) - expect(conversation()?.child).toBeNull() - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', rootGone: true }) - expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') - expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED - }) - // A Stop is not a failure: no row, and the loop is gone. - expect(await statusRows()).toEqual([]) - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - - const next = await accept('after stop') - await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(conversation()?.journal).toBe(journal) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) - // The reader opened before the Stop saw the next message delivered on the same stream. - expect( - events.some( - (event) => - event.type === 'batch' && - event.batch.submissions.some( - (entry) => entry.clientMessageId === next && entry.dispatchState === 'accepted' - ) - ) - ).toBe(true) - }) -}) - -describe('settling an earlier child before the next one takes its message', () => { - it("settles the earlier child's turn from its death evidence and leaves the queued message to the new child (R1)", async () => { - // The earlier child exited mid-turn; the released lease keeps only its death evidence. - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - lease: { - ...record.lease, - deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW - 1_000 } - } - })) - const releasedFence = store.getRecord(SESSION)!.lease.runtimeFence - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: HOST_TEST_LOCATION.workspaceId, - hostId: HOST_TEST_LOCATION.executionHostId, - agent: 'codex', - providerHandle: codexProviderHandle(THREAD) - }, - database: openTestJournalHostDatabase(root) - }) - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, - { kind: 'turn', turnId: 'earlier-turn', state: 'running', startedAt: NOW - 5_000 }, - { fence: releasedFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - const id = await accept('for the next child') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - // The exit was observed, so its receipt ends the turn, and the chat says why it stopped. - const items = conversation()!.journal.snapshot().items - expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( - expect.objectContaining({ - turnId: 'earlier-turn', - state: 'interrupted', - completedAt: NOW - 1_000 - }) - ) - expect( - items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain( - 'Codex stopped while this response was in progress. You can continue in this conversation.' - ) - // Handed over at the new child's fence, which the attach reserved after settling. - const newFence = store.getRecord(SESSION)!.lease.runtimeFence - expect(newFence).toBeGreaterThan(releasedFence) - expect((await submission(id))?.fence).toBe(newFence) - expect(conversation()?.child).toMatchObject({ generation: generation(), fence: newFence }) - }) -}) - -const START_EXIT = 'claude stream-json exited (code 1)' -const START_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' -const START_FAILURE: SubmissionRejectionFact = { - kind: 'providerStartFailed', - detail: { text: START_EXIT, audience: 'log' } -} - -describe('a published child that dies while it proves its start', () => { - const EXIT = START_EXIT - const TEXT = START_TEXT - - it('leaves one error row keyed by the start, and every message held for it rejected with it (R2)', async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // Both are held for the starting child, which never proves its start. - const first = await accept('first') - const events = await subscribe() - const second = await accept('second') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - const child = currentChild() - - await exit(child, EXIT, true) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - for (const id of [first, second]) { - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: TEXT, - rejection: START_FAILURE - }) - } - expect(rejectedIn(events, second)).toBe(true) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -/** A start by an operation that needs the agent, such as a goal change, outside the loop. */ -function startForOperation() { - return host['serialize'](SESSION, () => - ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) - ) -} - -describe('a start another operation made that dies while a sent message waits on it', () => { - const EXIT = START_EXIT - const TEXT = START_TEXT - - it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // An operation that needs the agent starts a child that has not proven its start. - await startForOperation() - const operationChild = currentChild() - const events = await subscribe() - const params = sendParams('hello') - - // Accepted first; that child's exit is settled before the loop's first step. - const sent = host.send(CALLER, params) - const exited = exit(operationChild, EXIT, true) - expect(await sent).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await exited - const id = params.envelope.clientOperationId - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - await settleLoop() - expect((await submission(id))?.reason).toBe(TEXT) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - expect(rejectedIn(events, id)).toBe(true) - // The setup's child and the operation's: nothing started again into the same failure. - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('leaves a message sent after that start failed to a fresh start (R2)', async () => { - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - await startForOperation() - await exit(currentChild(), EXIT, true) - expect(await statusRows()).toHaveLength(1) - - const id = await accept('after the failure') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - expect(acquire).toHaveBeenCalledTimes(3) - }) - - it('starts again for a message whose proven child crashed: only a failed start settles it (R2)', async () => { - await restartHost() - await startForOperation() - const params = sendParams('hello') - - const sent = host.send(CALLER, params) - const exited = exit(currentChild(), 'codex app-server crashed') - await sent - await exited - const id = params.envelope.clientOperationId - - await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) - expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) - }) - - it('hands over to a child started since the failed one, not to the failure (R2)', async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - await startForOperation() - const params = sendParams('hello') - - // A second operation's start lands after the first child's exit, before the loop's first step. - const sent = host.send(CALLER, params) - const exited = exit(currentChild(), EXIT, true) - const held = startForOperation() - await sent - await exited - await held - const id = params.envelope.clientOperationId - await prove() - - await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) - expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) - }) -}) - -describe('a child that ends before its message is handed over', () => { - it('starts one child for the message, then rejects it and stops (R2)', async () => { - await restartHost() - // The child the loop starts proves its start, then exits as its start step returns: that exit, - // asked for on the lane then, lands before the handover step. - acquire.mockImplementation(async (input) => { - const child = await spawnStartingChild(input) - const { acquisitionGeneration } = child - if (acquisitionGeneration === undefined) { - throw new Error('spawnChild always names a generation') - } - void starts.prove({ sessionId: SESSION, fence: input.fence, acquisitionGeneration }) - void host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: input.fence, - acquisitionGeneration, - reason: 'codex app-server crashed', - failure: { - kind: 'providerExited', - detail: { text: 'codex app-server crashed', audience: 'log' } - }, - cause: 'unexpected-exit' - }) - return child - }) - const id = await accept('hello') - const events = await subscribe() - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - // The exit's stderr rides beside the sentence, never in it. - const failure = { - kind: 'providerStartFailed', - detail: { text: 'codex app-server crashed', audience: 'log' } - } - expect(await submission(id)).toMatchObject({ - reason: 'Codex stopped before it finished starting. Send your message to try again.', - rejection: { ...failure, kind: 'providerStartFailed' } - }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } - ]) - expect(rejectedIn(events, id)).toBe(true) - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -describe('another child indexed after a Stop ended the starting one', () => { - it('hands the next message to the child now there once it proves its start (R2)', async () => { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // Held for the starting child, which never proves its start. - const first = await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - const stopped = currentChild() - expect(await stop()).toMatchObject({ ok: true }) - // An operation that needs the agent starts its own child before the second message is sent. - await host['serialize'](SESSION, () => - ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) - ) - const replacement = currentChild() - expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) - const secondId = await accept('second') - expect((await submission(secondId))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - await host.flushStreamedEvents(SESSION) - expect((await submission(secondId))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - - await prove() - - await eventually(async () => - expect((await submission(secondId))?.dispatchState).toBe('accepted') - ) - expect(conversation()?.child).toMatchObject({ - generation: replacement.acquisitionGeneration, - phase: 'ready' - }) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([secondId]) - expect(acquire).toHaveBeenCalledTimes(3) - expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) - }) -}) - -// A quit settles nothing still queued: the next launch's open keeps a person's message as a held -// card and rejects the rest, as a crash's would. -describe('a quit with a message still queued', () => { - const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { - surface: 'rejection' - }) - - /** Read by the next launch, through the same open any reader takes. */ - async function afterRelaunch(id: string): Promise { - startHost() - await host.revealSession(SESSION) - return await submission(id) - } - - async function keptCards(): Promise { - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - return page.ok ? (page.page.queuedMessages ?? []).map((card) => card.messageId) : [] - } - - it('keeps a person’s message no child ever had as a held card for the next launch (R2)', async () => { - // Quit has begun — its first step stops the delivery loops — when this message is accepted. - host['conversationDelivery'].loop.dispose() - const id = await accept('hello', { person: true }) - expect(conversation()?.child).toBeNull() - await host.flushAllStreamedEvents() - - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([id]) - expect(dispatch).not.toHaveBeenCalled() - }) - - it("rejects a message from Orca itself at the next launch, as a crash's would", async () => { - host['conversationDelivery'].loop.dispose() - const id = await accept('from orca') - await host.flushAllStreamedEvents() - - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([]) - }) - - it('stops a start already in flight before it launches a child (R2)', async () => { - const starting = deferred() - const closeSession = vi.fn(async () => true) - adapterExtras = { closeSession } - await restartHost() - // The loop's start step is under way, but has not reached its attach yet. - const resolveRecovery = host['runtimeState'].resolveRecovery.bind(host['runtimeState']) - const recovering = vi.spyOn(host['runtimeState'], 'resolveRecovery') - recovering.mockImplementationOnce(async (sessionId) => { - await starting.promise - return resolveRecovery(sessionId) - }) - const id = await accept('hello', { person: true }) - await eventually(() => expect(recovering).toHaveBeenCalled()) - const acquiresBefore = acquire.mock.calls.length - - const quit = host.flushAllStreamedEvents() - starting.resolve() - await quit - - // Quit aborts the start, so nothing is launched behind it and nothing is left to stop. - expect(acquire).toHaveBeenCalledTimes(acquiresBefore) - expect(closeSession).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { detail: 'reservation failed before spawn' } - }) - expect(dispatch).not.toHaveBeenCalled() - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([id]) - }) -}) - -describe('a send whose start failed, sent again with the same operation id', () => { - it('replays the recorded rejection and starts no second agent (R2)', async () => { - acquire.mockRejectedValueOnce(new Error('spawn claude ENOENT')) - const fenceBefore = store.getRecord(SESSION)!.lease.runtimeFence - const params = sendParams('hello') - // A failed start is a rejected message, never a refused send. - expect(await host.send(CALLER, params)).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - const id = params.envelope.clientOperationId - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - // The start moved the fence while the message was out. - expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) - const starts = acquire.mock.calls.length - - const replay = await host.send(CALLER, params) - - expect(replay).toMatchObject({ - ok: true, - replayed: true, - value: { submission: { clientMessageId: id, dispatchState: 'rejected' } } - }) - await settleLoop() - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -async function settleLoop(): Promise { - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) -} - -describe('how a stopped child ends the start its loop was waiting on', () => { - /** A starting child the first message is held for, then the stop below; the message sent after - * the stop is the next delivery's. */ - async function stoppedWhileStarting(stop: () => Promise) { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - await stop() - return accept('second') - } - - it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { - const second = await stoppedWhileStarting(async () => { - expect(await stop()).toMatchObject({ ok: true }) - }) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', reason: null }) - expect(await statusRows()).toEqual([]) - }) - - /** The close's stop alone: a close that aborts after it leaves the conversation indexed. */ - function closeStopOnly() { - return host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'user-close' - }) - ) - } - - /** A message queued for a starting child when the close's stop cut it: asked for during the - * start step, the stop runs before the handover. `beforeStart` runs after the stop and before - * the loop looks again. */ - async function closedWhileStarting(beforeStart: () => void = () => undefined) { - const start = deferred() - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(async (input) => { - await start.promise - return spawnStartingChild(input) - }) - const first = await accept('first') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - let starts = 0 - const closed = host['serialize'](SESSION, async () => { - await stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'user-close' - }) - starts = acquire.mock.calls.length - beforeStart() - }) - start.resolve() - await closed - await settleLoop() - return { first, starts } - } - - it('closes what was queued when the user closed the chat, and starts no child for it', async () => { - const { first, starts } = await closedWhileStarting() - - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - ...CHAT_CLOSED - }) - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - expect(await statusRows()).toEqual([]) - }) - - it('keeps a person’s message the user closed as a waiting card, and starts no child for it', async () => { - const start = deferred() - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - // The start step holds the queue: the message is still queued when the close's stop runs. - acquire.mockImplementationOnce(async (input) => { - await start.promise - return spawnStartingChild(input) - }) - const first = await accept('first', { person: true }) - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - let starts = 0 - const closed = closeStopOnly().then(() => { - starts = acquire.mock.calls.length - }) - start.resolve() - await closed - await settleLoop() - - expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - // The close stopped the chat running: the card waits for its next turn, by a mark the - // re-check wrote once, when it kept the card. - const journal = conversation()!.journal - expect(structuredQueuePauses(journal).map((pause) => pause.reason)).toEqual(['restarted']) - const marks = vi.spyOn(journal, 'appendQueueReopen') - await accept('second', { person: true }) - await settleLoop() - // A later send's re-check settles nothing, so it marks nothing past that send. - expect(marks).not.toHaveBeenCalled() - }) - - it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { - const { first, starts } = await closedWhileStarting(() => { - const journal = conversation()!.journal - const resolve = journal.resolveDispatch.bind(journal) - vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (...args) => { - if (args[0].state === 'rejected' && args[0].rejection?.kind === 'chatClosed') { - vi.mocked(journal.resolveDispatch).mockImplementation(resolve) - throw new Error('disk full') - } - return resolve(...args) - }) - }) - - expect((await submission(first))?.dispatchState).toBe('pending') - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - - const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - ...CHAT_CLOSED - }) - expect(dispatch).toHaveBeenCalledTimes(1) - }) - - it('goes on with a message sent in a later epoch, whose sequence restarts at or below the close', async () => { - await closedWhileStarting() - const session = conversation()! - await session.journal.rollEpoch( - 'corruption', - structuredAgentSessionConversationFence(store, SESSION) - ) - - const second = await accept('second') - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(session.lastEndedChild).toMatchObject({ cause: 'user-close' }) - expect((await submission(second))!.acceptedSequence).toBeLessThanOrEqual( - session.lastEndedChild!.endedAt.sequence - ) - }) - - it('goes on with a message sent after the close, never failing it', async () => { - const second = await stoppedWhileStarting(closeStopOnly) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' }) - expect(await statusRows()).toEqual([]) - }) - - it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { - const reason = 'the start watchdog fired' - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - const first = await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - await host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'host-stop', - reason - }) - ) - - // What was held for the stopped start fails with it, said once; a later send starts afresh. - await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) - // The sentence is the constructor's, not the reason the stop was given. - const text = 'Codex never finished starting, so Orca stopped it.' - expect(await submission(first)).toMatchObject({ - reason: text, - rejection: { kind: 'hostStopped' } - }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } - ]) - const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts index 981636f2caa..30d3dd497fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -5,7 +5,7 @@ import type { AgentSessionProviderHandleLink } from '../../../shared/agent-sessi export type StructuredAgentSessionProviderOpenInput = { sessionId: string fence: number - acquisitionGeneration: string | null + acquisitionGeneration: string signal: AbortSignal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index fbc8ba79719..50126091591 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -24,7 +24,6 @@ function awaitOpen() { } state.host.deps.adapter.acquire = async (input) => ({ ...(await acquire(input)), - providerChildPhase: 'starting', acquisitionGeneration: 'awaited-open' }) const pending = Promise.withResolvers() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts index 9e9020fdb1f..537957a743f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -17,18 +17,21 @@ export async function openStructuredAgentSessionProviderChild( sessionId: string, child: StructuredAgentSessionProviderChildIdentity ): Promise { - const open = context.deps.adapter.open - if (!open || !context.runtimeState.startupAttempts.onClock(sessionId, child)) { + if (!context.runtimeState.startupAttempts.onClock(sessionId, child)) { return } const attempt = context.runtimeState.acquireAborts.begin(sessionId) try { attempt.signal.throwIfAborted() + const acquisitionGeneration = child.generation + if (acquisitionGeneration === null) { + throw new Error('An unopened provider child has no acquisition generation') + } const result = await waitForPromiseWithSignal( - open.call(context.deps.adapter, { + context.deps.adapter.open({ sessionId, fence: child.fence, - acquisitionGeneration: child.generation, + acquisitionGeneration, signal: attempt.signal }), attempt.signal @@ -38,13 +41,7 @@ export async function openStructuredAgentSessionProviderChild( } await settleStructuredAgentSessionProviderStarted( context, - { - ...result, - type: 'started', - sessionId, - fence: child.fence, - acquisitionGeneration: child.generation ?? '' - }, + { ...result, sessionId, fence: child.fence, acquisitionGeneration }, attempt.signal ) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts deleted file mode 100644 index 09421a97cc5..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CLAUDE_SESSION = 'claude-session' -const hosts: StructuredAgentSessionHost[] = [] -let root = '' - -function claudeAdapter(): StructuredAgentSessionAdapter { - return { - supportsCreate: (_location, agent) => agent === 'claude', - acquire: async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle(CLAUDE_SESSION, null), - origin: 'created', - mintedAtFence: fence, - observedAt: HOST_TEST_NOW - } - }), - dispatch: async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } -} - -function createHost( - store: AgentSessionRecordStore, - probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] -): StructuredAgentSessionHost { - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: claudeAdapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner, - now: () => HOST_TEST_NOW - }) - hosts.push(host) - return host -} - -afterEach(async () => { - await Promise.all(hosts.splice(0).map(abandonStructuredAgentSessionHost)) - await rm(root, { recursive: true, force: true }) - root = '' -}) - -describe('structured session provider restore', () => { - it('restores a durable Claude session tab with its recorded provider', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-provider-restore-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const host = createHost(store) - const attached = await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } - }) - ) - expect(attached).toMatchObject({ ok: true }) - - const reopenedStore = await openTestAgentSessionRecordStore(root) - const restarted = createHost(reopenedStore, async () => ({ - outcome: 'indeterminate', - reason: 'read does not need ownership' - })) - - await restarted.restoreReadableSessions() - - expect(restarted.listSessionTabs()).toEqual([ - { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts deleted file mode 100644 index 07c5d80bc3e..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -// A chat that proves its start shows its account can start one, so the model catalog's held -// "can't start" reason is made due for the probe instead of trusted until its TTL runs out. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -const providerStarted = vi.fn() - -function startHost(): void { - const acquire: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: 'generation-1' - }) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire, - releaseAcquisition: vi.fn(async () => true), - closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - modelCatalog: { - read: vi.fn(), - retainedLiveModelIds: vi.fn(async () => []), - recordLiveListing: vi.fn(), - prewarm: vi.fn(async () => {}), - stop: vi.fn(), - providerStarted - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-started-catalog-')) - resetHostTestOperationIds() - providerStarted.mockReset() - store = await openTestAgentSessionRecordStore(root) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -/** The adapter's report that the child's protocol session exists. */ -function proveStart(): Promise { - return host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'sonnet' } - }) -} - -it('a published child tells the catalog only once its start is proven, with its record', async () => { - startHost() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) - expect(providerStarted).not.toHaveBeenCalled() - await proveStart() - expect(providerStarted).toHaveBeenCalledTimes(1) - expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) -}) - -it('a re-attach to the live child proves nothing new and tells the catalog nothing', async () => { - startHost() - const params = hostTestAttachParams(null) - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) - await proveStart() - const fence = store.getRecord(SESSION)?.lease.runtimeFence - // A reconnecting client replays its attach; the same operation re-attaches the live child. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) - expect(providerStarted).toHaveBeenCalledTimes(1) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts deleted file mode 100644 index 86163c25fe4..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -// A publish-first create proves nothing about the model until Claude answers startup. The record -// must never hold the catalog's default in the meantime: an owner handoff or a reopen would -// launch it as `--model` and silently move a user whose CLI default is not Sonnet. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' -import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' -import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' -import { - fakeClaude, - PROVIDER_SESSION_ID -} from '../../claude/claude-structured-session-test-support' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { createTestParams } from './structured-agent-session-create-test-fixture' - -const CALLER = { callerKey: 'client-1' } -const CLAUDE_MODELS = [ - { value: 'claude-sonnet', displayName: 'Sonnet' }, - { value: 'claude-opus-9', displayName: 'Opus 9' } -] -const LISTED = [ - expect.objectContaining({ id: 'claude-sonnet' }), - expect.objectContaining({ id: 'claude-opus-9' }) -] -const INIT_DELAY_MS = 40 - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let adapter: ClaudeStructuredSessionAdapter -let lifecycle: Promise[] -let statuses: AgentSessionStatusEvent[] -let savedListings: { sessionId: string; listing: AgentModelCatalogLiveListing }[] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-provider-started-')) - resetHostTestOperationIds() - lifecycle = [] - statuses = [] - savedListings = [] - // A CLI whose own default is not the catalog's: startup reports it through get_settings, - // since system/init arrives only with the first command. - const claude = fakeClaude({ - initDelayMs: INIT_DELAY_MS, - initModel: 'claude-opus-9', - settings: { - applied: { model: 'claude-opus-9', effort: 'high', advisor: null, ultracode: false }, - effective: { model: 'claude-opus-9', effortLevel: 'high', env: {} }, - sources: {} - }, - // Startup lists from initialize, and a chat's options read from list_models: the same rows. - initModels: CLAUDE_MODELS, - routes: { list_models: () => CLAUDE_MODELS } - }) - adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: root, - claudeConfigDir: join(root, 'claude-home'), - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - // A session that already minted its provider handle resumes it, as the real launch does. - resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, - continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 - }), - // The runtime's own mapping, so this test drives the same lifecycle path production does. - onEvent: (event) => { - const mapped = structuredClaudeLifecycleEvent(event) - if (mapped) { - lifecycle.push(host.handleAdapterEvent(mapped)) - } - }, - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => NOW - }) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - // The production router is what declares create support; the bare adapter only knows locations. - adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - modelCatalog: { - read: async () => ({ origin: 'unknown' }), - retainedLiveModelIds: vi.fn(async () => []), - recordLiveListing: (sessionId, listing) => savedListings.push({ sessionId, listing }), - prewarm: async () => {}, - stop: () => {}, - providerStarted: () => {} - }, - now: () => NOW - }) - host.subscribeStatus({ id: 'status-1', emit: (event) => statuses.push(event) }) -}) - -afterEach(async () => { - await adapter.closeAll() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function claudeParams(expectedRuntimeFence: number | null = null) { - return hostTestAttachParams(expectedRuntimeFence, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } - }) -} - -function lastPhase(): string | undefined { - const last = statuses.findLast((event) => event.type === 'status') - return last?.type === 'status' ? last.session.hostExecutionPhase : undefined -} - -describe('a publish-first Claude create whose init is slow', () => { - it('keeps provider defaults out of saved picks after starting', async () => { - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - - // Published, not yet answering: the record holds no model rather than a guessed one. - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(lastPhase()).toBe('starting') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(lastPhase()).toBe('ready') - }) - - it('keeps the saved model as intent while starting, then confirms what the child runs', async () => { - const params = claudeParams() - await expect( - host.attach(CALLER, { ...params, options: { model: 'opus' } }) - ).resolves.toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('ready') - }) - - it('keeps the picked model across a resume whose new child starts on its own default', async () => { - const params = claudeParams() - await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - await host.close(SESSION, 'evict') - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - // Starting the chat again resumes the session under a new fence. - await expect(host.attach(CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ - ok: true - }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) - // The new child is launched with the saved pick, whatever its CLI default. - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('starting') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('ready') - }) - it('saves the account listing the child read at startup, though no view asks for options', async () => { - await host.attach(CALLER, claudeParams()) - expect(savedListings).toEqual([]) - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - // `started` hands over the listing; the settings readback after it, what the config resolved. - expect(savedListings).toEqual([ - { sessionId: SESSION, listing: expect.objectContaining({ models: LISTED }) }, - { - sessionId: SESSION, - listing: expect.objectContaining({ - models: LISTED, - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - } - ]) - expect(savedListings[0]?.listing).not.toHaveProperty('configuredDefault') - }) - it('reports the configured default from a chat created with its first message and no pick', async () => { - const first = { clientMessageId: 'opening', body: hostTestMessage('hello') } - await expect( - host.create(CALLER, createTestParams(first, claudeParams()), { firstMessage: first }) - ).resolves.toMatchObject({ ok: true }) - - // Delivery, not create, starts the CLI. Its `started` event carries the account listing; the - // settings read after it (`options-reported`) resolves the configured model and says so once. - await vi.waitFor(() => expect(savedListings.length).toBeGreaterThanOrEqual(1)) - expect(savedListings[0]).toEqual({ - sessionId: SESSION, - listing: expect.objectContaining({ models: LISTED }) - }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(savedListings.at(-1)).toEqual({ - sessionId: SESSION, - listing: expect.objectContaining({ - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - }) - // A later read refreshes the listing without re-learning the configured default. - const saved = savedListings.length - await host.readOptions(SESSION) - expect(savedListings).toHaveLength(saved + 1) - expect(savedListings.at(-1)?.listing).not.toHaveProperty('configuredDefault') - }) - - it('saves the configured default when the settings readback beats the attach', async () => { - // The attach is slow to prove the owner, so the child's readback lands before the host indexes it. - const proveOwner = store.proveOwner.bind(store) - store.proveOwner = async (input) => { - await new Promise((resolve) => setTimeout(resolve, 200)) - return proveOwner(input) - } - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - await vi.waitFor(() => - expect(savedListings.at(-1)?.listing).toMatchObject({ - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index c8baf234501..00eafb4e339 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -1,9 +1,7 @@ // Opening records the provider handle and recovers history before delivery; reports never replace picks. -import type { - StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionStartedEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionOptionsReportedEvent } from './structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, @@ -21,7 +19,12 @@ import { import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition' -import { recoverStructuredRewind } from './structured-rewind-recovery' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + applyStructuredRewindRecovery, + readStructuredRewindRecovery, + type StructuredRewindRecoveryEvidence +} from './structured-rewind-recovery' export type StructuredAgentSessionProviderStartedContext = { deps: StructuredAgentSessionHostDeps @@ -40,11 +43,19 @@ export type StructuredAgentSessionProviderStartedContext = { stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void } +type OpenedProvider = StructuredAgentSessionProviderOpenResult & { + sessionId: string + fence: number + acquisitionGeneration: string +} + export async function settleStructuredAgentSessionProviderStarted( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent, + event: OpenedProvider, signal?: AbortSignal ): Promise { + // The provider read runs before the commit, so no send waits behind it. + const rewind = await readRewindAtStart(context, event, signal) // Serialized behind the attach that published this child, so the lease it proved is committed. await context.serialize(event.sessionId, async () => { signal?.throwIfAborted() @@ -64,7 +75,7 @@ export async function settleStructuredAgentSessionProviderStarted( context.stopStartingChild(event.sessionId, child) return null } - if (!(await recoverRewindAtStart(context, event.sessionId, session, signal))) { + if (!(await applyRewindAtStart(context, event.sessionId, session, rewind))) { context.stopStartingChild(event.sessionId, child) return null } @@ -89,46 +100,97 @@ export async function settleStructuredAgentSessionProviderStarted( /** A rewind left in doubt is settled by the first child that can read the provider's history: * this one, now that its protocol session is open, before it is handed anything a recovered * rewind would move. One that stays in doubt fails the start, as it would refuse every send. */ -async function recoverRewindAtStart( +async function readRewindAtStart( + context: StructuredAgentSessionProviderStartedContext, + event: OpenedProvider, + signal?: AbortSignal +): Promise { + const { sessionId } = event + const journal = context.sessions.get(sessionId)?.journal + if (!journal) { + return null + } + try { + return await readStructuredRewindRecovery( + context.deps, + sessionId, + journal, + structuredAgentSessionConversationFence(context.deps.store, sessionId), + context.deps.adapter, + signal, + recordWithStartedHandle(context, event) + ) + } catch (error) { + return rewindRecoveryFailed(context, sessionId, error, signal) + } +} + +/** The record as the commit leaves it once it records the handle this start answered with, which + * is what the rewind's apply cuts against; a record that refuses it is read as it stands. */ +function recordWithStartedHandle( + context: StructuredAgentSessionProviderStartedContext, + { sessionId, fence, link }: OpenedProvider +): AgentSessionRecord | null { + const record = context.deps.store.getRecord(sessionId) + try { + return record && link + ? recordAgentSessionProviderHandle({ record, fence, link, now: context.now() }) + : record + } catch { + return record + } +} + +async function applyRewindAtStart( context: StructuredAgentSessionProviderStartedContext, sessionId: string, session: StructuredAgentSessionHostSession, - signal?: AbortSignal + evidence: StructuredRewindRecoveryEvidence | 'failed' ): Promise { + if (evidence === 'failed') { + return false + } try { - await recoverStructuredRewind( + await applyStructuredRewindRecovery( context.deps, sessionId, session.journal, structuredAgentSessionConversationFence(context.deps.store, sessionId), - context.deps.adapter, - undefined, - signal + evidence ) return true } catch (error) { - if (signal?.aborted) { - return false - } + rewindRecoveryFailed(context, sessionId, error) + return false + } +} + +function rewindRecoveryFailed( + context: StructuredAgentSessionProviderStartedContext, + sessionId: string, + error: unknown, + signal?: AbortSignal +): 'failed' { + if (!signal?.aborted) { context.deps.logger.warn('settling a rewind in doubt at the start failed', { scope: 'rewind-recovery', sessionId, error }) - return false } + return 'failed' } /** The handle the child's protocol session answered with, recorded before it is handed anything: * without it a later start could not resume what this child ran. False when there is none. */ async function recordStartedHandle( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent + event: OpenedProvider ): Promise { const { store, logger } = context.deps const { link } = event if (!link) { - // Only an acquisition that already held its handle may prove its start without one. + // Only a spawn that already held its handle may prove its open without one. return store.getRecord(event.sessionId)?.lease.provenHandleLinkId != null } try { @@ -158,8 +220,7 @@ export function noteStructuredAgentSessionProviderStarted( } } -/** What a ready child reports later, such as a read that came after its start: persisted as the - * start's report is, never ahead of a send. */ +/** A ready child's later read can enrich the saved account catalog. */ export async function settleStructuredAgentSessionOptionsReported( context: StructuredAgentSessionProviderStartedContext, event: StructuredAgentSessionOptionsReportedEvent diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index aa5844ef0ee..249d7b6f0a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -19,7 +19,7 @@ export function adapterSupportsCreate( return adapter.supportsLocation?.(location) ?? false } -/** Honors declared gates while retaining legacy adapters whose acquire path is authoritative. */ +/** Honors declared gates while retaining adapters whose spawn path is authoritative. */ export function adapterSupportsCreateIfDeclared( adapter: StructuredAgentSessionAdapter, location: AgentSessionExecutionLocation, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 5911f3bf7ab..692aaefdbd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -51,12 +51,25 @@ export function createQueuedRigProvider( const cancelTurn: Mock = vi.fn(async () => ({ cancelled: true })) + const setOption: Mock = vi.fn(async () => undefined) const closeSession: Mock> = vi.fn( async () => true ) let events: StructuredAgentSessionEventSink | undefined + let proveOpen: (() => void) | null = null const scripted: StructuredAgentSessionAdapter = { + open: () => + options.starting + ? new Promise((resolve) => { + proveOpen = () => + resolve({ + reportedOptions: { model: 'default' } + }) + }) + : Promise.resolve({ + reportedOptions: { model: 'default' } + }), acquire: async ({ identity, fence, spawnToken, events: sink }) => { starts() const hold = startHold @@ -75,7 +88,6 @@ export function createQueuedRigProvider( ? `${THREAD}-context-${record!.providerContextBoundary!.operationId}` : (context?.head?.handle.nativeId ?? THREAD) return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -101,7 +113,7 @@ export function createQueuedRigProvider( ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), ...(options.startUnanswered ? { startAnswered: () => false } : {}), answerPrompt: vi.fn(async ({ commit }) => commit()), - setOption: vi.fn(async () => undefined), + setOption, ...(options.rewind ? { rewind: options.rewind, rewindSupport: () => ({ supported: true as const }) } : {}), @@ -149,12 +161,14 @@ export function createQueuedRigProvider( return { adapter, + proveOpen: () => proveOpen?.(), dispatch, starts, holdNextStart, failNextStart, compact, cancelTurn, + setOption, closeSession, finishCompact, providerEvents diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index dc00adacc46..4b5a5b7fefc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -42,7 +42,7 @@ export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } type RigSendOptions = { internal?: true from?: AgentMessageSource - /** The model and effort the send carries. */ + /** The choices the send carries, fingerprinted as a client's are. */ carried?: Readonly> } @@ -68,7 +68,7 @@ export async function createQueuedMessageTestRig( resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) const provider = createQueuedRigProvider(store, options, () => host) - const { dispatch, compact, cancelTurn, closeSession } = provider + const { dispatch, compact, cancelTurn, closeSession, setOption } = provider const makeHost = () => new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), @@ -197,7 +197,7 @@ export async function createQueuedMessageTestRig( await result await eventually(async () => { if (host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase === 'starting') { - await emitStarted() + await proveProviderOpen() } expect((await submission(id))?.handedOverAt).toBeDefined() }) @@ -268,23 +268,17 @@ export async function createQueuedMessageTestRig( }) } - /** The starting child proves its start, as a publish-first provider's `started` event does: the - * host hands it what it held. */ + /** The starting child proves its open before the host hands it queued work. */ async function proveStart(): Promise { await eventually(() => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') ) - await emitStarted() + await proveProviderOpen() } - function emitStarted(): Promise { - return host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' } - }) + async function proveProviderOpen(): Promise { + provider.proveOpen() + await Promise.resolve() } async function dispose(): Promise { @@ -299,10 +293,10 @@ export async function createQueuedMessageTestRig( return host }, dispatch, + setOption, cancelTurn, closeSession, compact, - setOption: provider.adapter.setOption, starts: provider.starts, holdNextStart: provider.holdNextStart, failNextStart: provider.failNextStart, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 99d276c64ca..7faef032f13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -36,6 +36,7 @@ import { nextSendableQueuedCard } from '../agent-session-journal/queued-message- import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { agentSessionAttachmentExpiredRefusal } from './structured-agent-session-turns' import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' +import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' /** Waiting, not held on its own, and not positioned behind a returned card or a * card the queue's pause holds: the queue never reorders. The admission rule @@ -146,7 +147,14 @@ export async function maybeQueueStructuredAgentSessionSend( }, ctx: Pick< AgentSessionTurnContext, - 'sessionId' | 'journal' | 'fence' | 'operationReceipt' | 'currentWork' + | 'sessionId' + | 'journal' + | 'fence' + | 'operationReceipt' + | 'currentWork' + | 'agents' + | 'agent' + | 'persistedOptions' >, params: { envelope: { clientOperationId: string } @@ -199,6 +207,10 @@ export async function maybeQueueStructuredAgentSessionSend( if (refusal) { return { ok: false, refusal } } + const captured = captureStructuredExecutionOptions(ctx, params.options) + if ('refusal' in captured) { + return { ok: false, refusal: captured.refusal } + } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. let row: QueuedMessageRow @@ -211,7 +223,7 @@ export async function maybeQueueStructuredAgentSessionSend( fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), hostInstance: structuredAgentSessionHostInstance(), // Applied when the card goes out, to the turn it starts. - ...(params.options ? { options: params.options } : {}), + options: captured.options, ...(params.userSend ? { requireAttachments: true } : {}) }, ctx.operationReceipt diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts index 1539248f2df..012081ac321 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -96,3 +96,92 @@ it('rechecks a goal retry settled during startup, and replays it later without a expect(acquire).toHaveBeenCalledTimes(2) expect(changeThreadGoal).toHaveBeenCalledTimes(3) }) + +it.each([false, true])('keeps a goal behind active dispatch (ready child: %s)', async (ready) => { + const { host, dispatch } = hostTestState() + const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + host.deps.adapter.changeThreadGoal = changeThreadGoal + await attach() + if (ready) { + const change = { kind: 'set' as const, objective: 'Warm child' } + await host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + changeThreadGoal.mockClear() + } + const held = Promise.withResolvers<{ state: 'admitted' }>() + dispatch.mockImplementationOnce(() => held.promise) + const body = hostTestMessage('Earlier message') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + const change = { kind: 'set' as const, objective: 'Later goal' } + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + expect(changeThreadGoal).not.toHaveBeenCalled() + held.resolve({ state: 'admitted' }) + expect(await goal).toMatchObject({ ok: true }) + expect(changeThreadGoal).toHaveBeenCalledOnce() +}) + +it.each(['append', 'wait'] as const)( + 'Stop revokes a goal during %s through settlement', + async (phase) => { + const { host, dispatch } = hostTestState() + const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + host.deps.adapter.changeThreadGoal = changeThreadGoal + await attach() + const change = { kind: 'set' as const, objective: 'Warm child' } + await host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + changeThreadGoal.mockClear() + const held = Promise.withResolvers() + if (phase === 'wait') { + dispatch.mockImplementationOnce(async () => { + await held.promise + return { state: 'admitted' } + }) + const body = hostTestMessage('Earlier message') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + } + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const append = vi.spyOn(journal, 'appendItem') + const withdraw = vi.spyOn(journal, 'appendTombstone') + if (phase === 'append') { + const original = journal.appendItem.bind(journal) + append.mockImplementationOnce(async (...args) => { + await held.promise + return original(...args) + }) + } + const next = { ...change, objective: 'Later goal' } + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change: next }), + change: next + }) + const result = goal.catch((error: unknown) => error) + await (phase === 'append' + ? vi.waitFor(() => expect(append).toHaveBeenCalledOnce()) + : host.collaboratorsForTests().serialize(SESSION, async () => {})) + const stopped = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + expect(changeThreadGoal).not.toHaveBeenCalled() + held.resolve() + if (phase === 'append') { + expect(await result).toBeInstanceOf(Error) + expect(withdraw).toHaveBeenCalledOnce() + } else { + expect(await result).toMatchObject({ ok: false }) + } + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(changeThreadGoal).not.toHaveBeenCalled() + if (phase === 'append') { + expect(append.mock.calls.some(([, body]) => body.kind === 'status')).toBe(true) + } + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index 99ecf106e64..dbbc008a2a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -2,22 +2,15 @@ import { agentSessionFingerprintConflict, computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { refuse, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionWireRefusal + AgentSessionMutationResult } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' -import { sameProviderChild } from './structured-agent-session-provider-child' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' -import { - openForWrite, - openForProviderWrite, - rewindInDoubt -} from './structured-agent-session-send-preparation' +import { openForWrite, rewindInDoubt } from './structured-agent-session-send-preparation' import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { replayRecordedOperation, @@ -29,8 +22,23 @@ import { commandReceiptScope } from '../agent-session-journal/command-receipt-sc import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' import { waitForStructuredAgentSessionStart } from './structured-agent-session-start-readiness' import type { StructuredAgentSessionDeliveryPlace } from './structured-agent-session-delivery-order' +import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' + +import { + operationDidNotRun, + selectStructuredAgentSessionOperationChild, + type ReadyOperationSelection +} from './structured-agent-session-operation-child' +export { + openWithReadyStructuredAgentSessionChild, + operationDidNotRun, + type ReadyOperationChild, + type ReadyOperationSelection +} from './structured-agent-session-operation-child' + type ReadyOperationPlan = Pick< MutationPlan, 'method' | 'fields' | 'operationIdScope' | 'commandReceipt' @@ -41,19 +49,13 @@ type ReadyOperationPlan = Pick< type QueuedReadyOperation = | { result: AgentSessionMutationResult } | { - child: StructuredAgentSessionProviderChildIdentity - readiness: StructuredAgentSessionStartReadiness + /** Null: the session has no child to run on yet, so the operation starts one outside. */ + child: StructuredAgentSessionProviderChildIdentity | null + readiness: StructuredAgentSessionStartReadiness | null + start: StructuredAgentSessionStartClaim | null place: StructuredAgentSessionDeliveryPlace } -export type ReadyOperationChild = StructuredAgentSessionProviderChildIdentity | null -export type ReadyOperationSelection = { - ok: true - child: ReadyOperationChild - replay?: true - deadline?: number -} - /** A send or conversation command waits only when an earlier rewind needs its provider. */ export async function runAfterRewindStart( context: StructuredAgentSessionMutationContext, @@ -67,26 +69,8 @@ export async function runAfterRewindStart( }) } -export function operationDidNotRun(expired = false): { - ok: false - refusal: AgentSessionWireRefusal -} { - return { - ok: false, - refusal: expired - ? refuse( - 'agent_session_operation_expired', - { reason: 'operationExpired' }, - 'The operation did not run because its wait expired. Try again.' - ) - : refuseUnclassified( - 'agent_session_owner_restart_failed', - 'The operation did not run because the agent is not ready.' - ) - } -} - -/** A ready child runs in this queue turn; only a starting child releases it to wait. */ +/** A ready child runs in this queue turn; a starting child, or none, releases it to wait, and a + * start the operation needs runs outside the queue, as the delivery loop's does. */ export async function runWithReadyStructuredAgentSessionOperation( context: StructuredAgentSessionMutationContext, callerKey: string, @@ -97,8 +81,8 @@ export async function runWithReadyStructuredAgentSessionOperation( beforeAgent?: ( record: AgentSessionRecord | null ) => AgentSessionMutationSessionPreparation | null + goal?: boolean needsAgent?: (record: AgentSessionRecord | null) => boolean - runAfterAgentEnds?: true } = {} ): Promise> { const hostFingerprint = computeAgentSessionPayloadFingerprint({ @@ -111,9 +95,13 @@ export async function runWithReadyStructuredAgentSessionOperation( return { ok: false, refusal: conflict } } const deadline = Date.now() + STRUCTURED_AGENT_SESSION_START_WAIT_MS - const selected = await context.serialize>( + let authority: ReturnType | undefined + const preparedOperation = context.serialize>( envelope.sessionId, async () => { + authority = options.goal + ? context.acquireAborts.begin(envelope.sessionId, false, true) + : undefined const record = context.deps.store.getRecord(envelope.sessionId) if (options.needsAgent && !options.needsAgent(record)) { return { result: await run({ ok: true, child: null }) } @@ -212,24 +200,7 @@ export async function runWithReadyStructuredAgentSessionOperation( if (checked && !checked.ok) { return checked } - const ensured = await context.ensureAgent(envelope.sessionId) - if (!ensured.ok) { - return ensured - } - const child = context.sessions.get(envelope.sessionId)?.child - if (!child || child.close) { - return operationDidNotRun() - } - const identity = { generation: child.generation, fence: child.fence } - if (child.phase === 'ready') { - return { ok: true as const, child: identity, deadline } - } - const readiness = context.startupAttempts.readiness(envelope.sessionId, identity) - return readiness - ? { ok: true as const, child: identity, readiness } - : options.runAfterAgentEnds - ? { ok: true as const, child: identity } - : operationDidNotRun() + return selectStructuredAgentSessionOperationChild(context, envelope.sessionId) } const child = await select().catch((error: unknown) => { context.deps.logger.warn('selecting an agent for an operation failed', { @@ -239,11 +210,20 @@ export async function runWithReadyStructuredAgentSessionOperation( }) return operationDidNotRun() }) + if (authority?.signal.aborted) { + return { result: operationDidNotRun() } + } if (!child.ok) { return { result: child } } - if (!child.readiness) { - return { result: await run(child) } + if ( + child.child && + !child.readiness && + !context.deliveryOrder.hasActiveSend(envelope.sessionId) + ) { + return { + result: await run({ ok: true, child: child.child, deadline, signal: authority?.signal }) + } } context.deliveryOrder.reconcile( envelope.sessionId, @@ -251,60 +231,78 @@ export async function runWithReadyStructuredAgentSessionOperation( ) const place = context.deliveryOrder.reserve(envelope.sessionId) return { + start: child.child ? null : context.claimAgent(envelope.sessionId), child: child.child, readiness: child.readiness, place } } ) + const selected = await preparedOperation.then( + (selection) => { + if ('result' in selection) { + authority?.end() + } + return selection + }, + (error: unknown) => { + authority?.end() + throw error + } + ) if ('result' in selected) { return selected.result } try { - const ready = selected.readiness.wait() - const outcome = await waitForStructuredAgentSessionStart( + let { child, readiness } = selected + if (!child) { + const started = await waitForPromiseWithSignal( + selected.start?.outcome ?? Promise.resolve(undefined), + authority?.signal + ).catch((error: unknown) => { + if (authority?.signal.aborted) { + return undefined + } + throw error + }) + if (!started?.ok) { + return started ?? operationDidNotRun() + } + const selection = selectStructuredAgentSessionOperationChild(context, envelope.sessionId) + if (!selection.ok || !selection.child) { + return selection.ok ? operationDidNotRun() : selection + } + child = selection.child + readiness = selection.readiness + } + const ready = readiness?.wait() ?? Promise.resolve('ready' as const) + const waiting = waitForStructuredAgentSessionStart( Promise.all([ready, selected.place.before]).then(([outcome]) => outcome === 'ready' ? 'ready' : 'ended' ), deadline - Date.now() ) - if (outcome !== 'ready' && !(outcome === 'ended' && options.runAfterAgentEnds)) { + const outcome = await waitForPromiseWithSignal(waiting, authority?.signal).catch( + (error: unknown) => { + if (authority?.signal.aborted) { + return 'ended' as const + } + throw error + } + ) + if (outcome !== 'ready') { return operationDidNotRun(outcome === 'timeout') } if (Date.now() >= deadline) { return operationDidNotRun(true) } + const ran = child return await context.serialize(envelope.sessionId, () => // Admission re-reads the journal here after readiness and answers a competing settlement. - run({ ok: true, child: selected.child, deadline }) + run({ ok: true, child: ran, deadline, signal: authority?.signal }) ) } finally { + authority?.end() selected.place.release() } } - -/** Replay uses the ledger answer; a new operation uses only its selected ready child. */ -export function openWithReadyStructuredAgentSessionChild( - context: StructuredAgentSessionMutationContext, - envelope: AgentSessionMutationEnvelope, - selection: ReadyOperationSelection -): (ledger: 'admit' | 'replay') => Promise { - return async (ledger) => { - const opened = await openForProviderWrite(context, envelope)() - if (!opened.ok || ledger === 'replay') { - return opened - } - if (!selection.child) { - return selection.replay - ? { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } - : operationDidNotRun() - } - if (selection.deadline !== undefined && Date.now() >= selection.deadline) { - return operationDidNotRun(true) - } - const child = context.sessions.get(envelope.sessionId)?.child - return child?.phase === 'ready' && !child.close && sameProviderChild(child, selection.child) - ? opened - : operationDidNotRun() - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index ff3967c3634..eb079409cff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -35,7 +35,7 @@ let acquire: Mock const spawnedOwners = new Set>() const supersededHosts = new Set() -async function spawnOwner(spawnToken: string) { +async function acquireOwner(spawnToken: string) { const child = spawnProcess({ program: process.execPath, args: ['-e', 'setInterval(() => {}, 1_000)'], @@ -73,13 +73,16 @@ async function startAgent(): Promise { function adapter(): StructuredAgentSessionAdapter { return { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, - releaseAcquisition: vi.fn(async () => undefined), + releaseAcquisition: vi.fn(async () => false), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), setOption: vi.fn() - } as unknown as StructuredAgentSessionAdapter + } } function openHost(overrides: Partial = {}): void { @@ -105,6 +108,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-recovery-exits-')) resetHostTestOperationIds() acquire = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -286,8 +290,9 @@ describe('recovery exits', () => { }) it('recovers when the outgoing runtime writes after the replacement probes its dying owner', async () => { - const outgoing = await spawnOwner('spawn-a') + const outgoing = await acquireOwner('spawn-a') acquire.mockResolvedValueOnce({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: outgoing.process, link: { linkId: 'link-outgoing', @@ -331,8 +336,9 @@ describe('recovery exits', () => { }) expect(acquire).toHaveBeenCalledOnce() - const replacement = await spawnOwner('spawn-b') + const replacement = await acquireOwner('spawn-b') acquire.mockResolvedValueOnce({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: replacement.process, link: { linkId: 'link-replacement', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 257072f7c23..7ba443d55e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -104,6 +104,10 @@ async function stopOwnerAndReprobe( const delay = deps.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'owner stop requested' } for (const signal of ['SIGTERM', 'SIGKILL'] as const) { + // Resolution runs outside the session's serialize: a lease that moved names a pid not ours. + if (!leaseStillNamesOwner(deps.store.getRecord(record.sessionId), record)) { + return probe + } stop(pid, signal) for (let attempt = 0; attempt < STOP_PROBES[signal]; attempt += 1) { probe = await deps.probeRecord(record) @@ -116,6 +120,19 @@ async function stopOwnerAndReprobe( return probe } +function leaseStillNamesOwner( + current: AgentSessionRecord | null, + probed: AgentSessionRecord +): boolean { + const owner = current?.lease.ownerProcess + const expected = probed.lease.ownerProcess + return ( + current?.lease.runtimeFence === probed.lease.runtimeFence && + owner?.pid === expected?.pid && + owner?.processStartTimeMs === expected?.processStartTimeMs + ) +} + function defaultStopOwnerProcess(pid: number, signal: StructuredSessionRecoveryStopSignal): void { try { process.kill(pid, signal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts deleted file mode 100644 index e443c55b9eb..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - AGENT_SESSION_WIRE_REFUSAL_CODES, - type AgentSessionMutationEnvelope, - type AgentSessionMutationResult, - type AgentSessionWireRefusalCode -} from '../../../shared/agent-session-wire' -import { - agentSessionRefusalOperationState, - type AgentSessionRefusalOperationState -} from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' - -const CALLER = { callerKey: 'client-1' } -const METHODS = ['agentSession.setOption', 'agentSession.send'] as const -type Method = (typeof METHODS)[number] -type Pair = `${Method}:${AgentSessionWireRefusalCode}` - -type CallSpec = { - method: Method - operationId: string - expectedRuntimeFence?: number - payloadFingerprint?: string -} - -type Harness = { - root: string - store: AgentSessionRecordStore - host: StructuredAgentSessionHost - setOption: Mock -} - -const harnesses: Harness[] = [] -let operationSequence = 1_000 - -function operationId(timestamp = NOW): string { - operationSequence += 1 - return `${timestamp}-${operationSequence.toString(16).padStart(32, '0')}` -} - -async function createHarness(options: { attached?: boolean } = {}) { - const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) - const store = await openTestAgentSessionRecordStore(root) - const setOption = vi.fn(async () => undefined) - const adapter: StructuredAgentSessionAdapter = startsWhenPublished( - { - acquire: async ({ fence }) => ({ - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - // A failed acquisition is proven gone, as the real adapters prove it. - releaseAcquisition: async () => true, - setOption - }, - () => host - ) - const host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - const harness = { root, store, host, setOption } - harnesses.push(harness) - if (options.attached !== false) { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - } - return harness -} - -afterEach(async () => { - const completed = harnesses.splice(0) - await Promise.all(completed.map(async ({ host }) => abandonStructuredAgentSessionHost(host))) - await Promise.all(completed.map(async ({ root }) => rm(root, { recursive: true }))) -}) - -function callFields(spec: CallSpec): Record { - if (spec.method === 'agentSession.send') { - return { body: hostTestMessage('host oracle') } - } - return { key: 'model', value: 'gpt-5' } -} - -function envelope(harness: Harness, spec: CallSpec): AgentSessionMutationEnvelope { - const fields = callFields(spec) - return { - sessionId: SESSION, - clientOperationId: spec.operationId, - expectedRuntimeFence: - spec.expectedRuntimeFence ?? harness.store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: - spec.payloadFingerprint ?? - computeAgentSessionPayloadFingerprint({ method: spec.method, sessionId: SESSION, fields }) - } -} - -function invoke(harness: Harness, spec: CallSpec): Promise> { - const fields = callFields(spec) - const mutationEnvelope = envelope(harness, spec) - if (spec.method === 'agentSession.send') { - return harness.host.send(CALLER, { - envelope: mutationEnvelope, - body: fields.body as ReturnType - }) - } - return harness.host.setOption(CALLER, { - envelope: mutationEnvelope, - key: fields.key as string, - value: fields.value as string - }) -} - -function operationState(harness: Harness, operation: string) { - return harness.store - .listOperationRows() - .find((row) => row.callerKey === CALLER.callerKey && row.operationId === operation)?.outcome -} - -async function assertHostAgreement( - harness: Harness, - spec: CallSpec, - code: AgentSessionWireRefusalCode, - retry?: () => Promise<{ harness: Harness; spec: CallSpec }> -): Promise { - try { - const result = await invoke(harness, spec) - expect(result, `${spec.method}:${code}`).toMatchObject({ ok: false, refusal: { code } }) - } catch (error) { - expect(error).toMatchObject({ message: code }) - } - const outcome = operationState(harness, spec.operationId) - let oracle: AgentSessionRefusalOperationState - if ( - harness.store.readCommandReceipt({ kind: 'global' }, spec.operationId).verdict === 'unreadable' - ) { - oracle = 'unknown' - } else if (outcome?.status === 'failed') { - oracle = 'settled-rejected' - } else if (outcome?.status === 'unknown') { - oracle = 'unknown' - } else if (outcome?.status === 'pending') { - oracle = 'pending-admission' - } else if (retry) { - const next = await retry() - await expect(invoke(next.harness, next.spec)).resolves.toMatchObject({ - ok: true, - replayed: false - }) - oracle = 'pending-admission' - } else { - oracle = 'settled-rejected' - } - expect(agentSessionRefusalOperationState(code), `${spec.method}:${code}`).toBe(oracle) - return `${spec.method}:${code}` -} - -async function setLease( - harness: Harness, - update: (record: AgentSessionRecord) => AgentSessionRecord -): Promise { - await harness.store.transitionHandoff(SESSION, update) -} - -// sendPlan and setOptionPlan have no unsupported branch. -const UNREACHABLE = new Set([ - 'agentSession.send:structured_agent_session_unsupported', - 'agentSession.setOption:structured_agent_session_unsupported', - // performPrompt is the sole producer of prompt revision and resolution refusals. - 'agentSession.setOption:agent_session_item_revision_stale', - 'agentSession.send:agent_session_item_revision_stale', - 'agentSession.setOption:agent_session_already_resolved', - 'agentSession.send:agent_session_already_resolved', - // StructuredAgentSessionHost.mutate maps an absent record to AGENT_SESSION_NOT_ATTACHED. - 'agentSession.setOption:agent_session_identity_required', - 'agentSession.send:agent_session_identity_required', - // Only a send opens the conversation it writes to. - 'agentSession.setOption:agent_session_journal_unreadable', - // Only a send restarts a lost owner. - 'agentSession.setOption:agent_session_owner_restart_failed', - // A write names its target, not an owner generation; only an attach compares fences. - 'agentSession.setOption:agent_session_checkpoint_stale', - 'agentSession.send:agent_session_checkpoint_stale', - // A send is a conversation write: admitted whoever owns the lease, and a start it needs that - // fails rejects the accepted message rather than refusing the call. - 'agentSession.send:agent_session_conflict', - 'agentSession.send:execution_owner_reconciling', - 'agentSession.send:agent_session_owner_restart_failed', - // Send admits through its receipt, which has no age budget. - 'agentSession.send:agent_session_operation_expired', - // The ledger has no count limit; only an older host still refuses with it. - 'agentSession.setOption:agent_session_operation_capacity', - 'agentSession.send:agent_session_operation_capacity' -]) - -describe('agentSessionRefusalOperationState host oracle', () => { - // Full host retries touch the durable store and successful cases rotate its backup. - it('agrees with every refusal the real host path can produce', { timeout: 90_000 }, async () => { - const produced = new Set() - const record = (pair: Pair) => produced.add(pair) - - const stale = await createHarness() - for (const method of METHODS) { - const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } - await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) - } - expect(stale.setOption).toHaveBeenCalledTimes(1) - - const conflict = await createHarness() - for (const method of ['agentSession.setOption'] as const) { - await setLease(conflict, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'new-owner-proving' } - })) - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(conflict, spec, 'agent_session_conflict', async () => { - await setLease(conflict, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: null } - })) - return { harness: conflict, spec } - }) - ) - } - - const absent = await createHarness({ attached: false }) - for (const method of METHODS) { - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(absent, spec, 'agent_session_ownership_unknown', async () => ({ - harness: await createHarness(), - spec - })) - ) - } - - const operationConflict = await createHarness() - for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { - record( - await assertHostAgreement( - operationConflict, - { - method, - operationId: operationId(), - payloadFingerprint: 'wrong' - }, - 'agent_session_operation_conflict' - ) - ) - } - const ledgerRefusals = await createHarness() - for (const [code, timestamp, methods] of [ - [ - 'agent_session_operation_expired', - NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, - ['agentSession.setOption'] - ], - ['agent_session_operation_invalid', null, METHODS] - ] as const) { - for (const method of methods) { - record( - await assertHostAgreement( - ledgerRefusals, - { - method, - operationId: timestamp === null ? 'invalid-operation-id' : operationId(timestamp) - }, - code - ) - ) - } - } - - const unknown = await createHarness() - unknown.setOption.mockRejectedValueOnce(new Error('reply lost')) - const optionUnknown = { method: 'agentSession.setOption' as const, operationId: operationId() } - await expect(invoke(unknown, optionUnknown)).rejects.toThrow('reply lost') - record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) - const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } - await expect(invoke(unknown, sendUnknown)).resolves.toMatchObject({ ok: true }) - openTestJournalHostDatabase(unknown.root) - .db.prepare( - "UPDATE agent_session_command_receipts SET result_json = '{' WHERE operation_id = ?" - ) - .run(sendUnknown.operationId) - record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) - - const reconciling = await createHarness() - for (const method of ['agentSession.setOption'] as const) { - await setLease(reconciling, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(reconciling, spec, 'execution_owner_reconciling', async () => { - await setLease(reconciling, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: false } - })) - return { harness: reconciling, spec } - }) - ) - } - - const unreadable = await createHarness() - await unreadable.host.close(SESSION, 'evict') - const unreadableOpen = vi - .spyOn(AgentSessionJournal.prototype, 'open') - .mockRejectedValue(new Error('journal path unreadable')) - const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } - record( - await assertHostAgreement( - unreadable, - unreadableSend, - 'agent_session_journal_unreadable', - async () => { - unreadableOpen.mockRestore() - return { harness: unreadable, spec: unreadableSend } - } - ) - ) - - const allPairs = METHODS.flatMap((method) => - AGENT_SESSION_WIRE_REFUSAL_CODES.map((code) => `${method}:${code}` as Pair) - ) - expect(new Set([...produced, ...UNREACHABLE])).toEqual(new Set(allPairs)) - expect([...produced].filter((pair) => UNREACHABLE.has(pair))).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts deleted file mode 100644 index 3998903e9b3..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -// The registry is the only source of an agent's definition: routing, declared capabilities and the -// option rules a chat at rest reads all follow what composition registered, and a definition this -// build ships but did not register decides nothing. - -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLiveOptions -} from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { - readStructuredAgentSessionOptions, - recordStructuredAgentSessionOptionIntent -} from './structured-agent-session-options-read' - -const RECORD: AgentSessionRecord = { - ...agentSessionRecordFixture(), - options: { model: 'pilot-model' } -} - -/** Claude, registered with rules unlike the ones Claude's own module declares. */ -const NON_DEFAULT: StructuredAgentDefinition = { - ...CLAUDE_STRUCTURED_AGENT, - capabilities: { ...CLAUDE_STRUCTURED_AGENT.capabilities, threadGoal: true }, - restingOptions: { - acceptsKey: (key) => key === 'pilotOption', - fallbackModels: () => [ - { id: 'pilot-model', label: 'Pilot', isDefault: true, defaultEffort: 'low', efforts: [] } - ], - effortDefaultsToModel: true - } -} - -function fakeAdapter(): StructuredAgentSessionAdapter { - return { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle('provider-session-1', null), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: 1 - } - })), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn(), - compact: vi.fn(), - changeThreadGoal: vi.fn() - } -} - -function pick(agents: StructuredAgentRegistry, record: AgentSessionRecord | null, key: string) { - const persistOptions = vi.fn(async () => {}) - const result = recordStructuredAgentSessionOptionIntent( - { store: { getRecord: () => record, replaceSessionOptions: vi.fn() }, agents }, - { sessionId: RECORD.sessionId, fence: 1, persistOptions, publish: vi.fn(), now: () => 0 }, - { key, value: 'enabled' } - ) - return { result, persistOptions } -} - -/** At rest, or from a running child that answers `live`. */ -function readAtRest(agents: StructuredAgentRegistry, live?: StructuredAgentSessionLiveOptions) { - const resting = { - child: live ? { fence: 1 } : null, - params: { provider: RECORD.provider }, - journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read touches only these members. - const context = { - deps: { - adapter: live - ? { readOptions: async () => live } - : new StructuredAgentSessionAdapterRouter(agents, async () => {}), - agents, - store: { getRecord: () => RECORD } - }, - serialize: (_sessionId: string, task: () => Promise) => task(), - openConversation: async () => resting, - conversation: async () => resting - } as unknown as StructuredAgentSessionMutationContext - return readStructuredAgentSessionOptions(context, RECORD.sessionId, { readsWithoutModel: true }) -} - -describe('a registered definition', () => { - it('routes, declares capabilities and rules options at rest for its agent', async () => { - const adapter = fakeAdapter() - const agents = new StructuredAgentRegistry([{ definition: NON_DEFAULT, adapter }]) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - - await router.acquire({ - identity: { - sessionId: 'session-routed', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'claude', - providerHandle: null - }, - fence: 1, - spawnToken: 'spawn-1' - }) - expect(adapter.acquire).toHaveBeenCalledOnce() - expect(agents.capabilities(RECORD.provider)).toBe(NON_DEFAULT.capabilities) - - const accepted = pick(agents, RECORD, 'pilotOption') - await expect(accepted.result).resolves.toMatchObject({ ok: true }) - expect(accepted.persistOptions).toHaveBeenCalledWith({ - model: 'pilot-model', - pilotOption: 'enabled' - }) - // Claude's own module accepts `model`; the registration says otherwise and wins. - const refused = pick(agents, RECORD, 'model') - await expect(refused.result).resolves.toMatchObject({ ok: false }) - - const options = await readAtRest(agents) - expect(options.models.map((model) => model.id)).toEqual(['pilot-model']) - // No pick for effort: the registered rules read the model's default. - expect(options.current).toEqual({ - model: 'pilot-model', - effort: 'low', - permissionMode: 'ask' - }) - expect(options.threadGoal).toEqual({ current: null }) - }) - - it('leaves an agent this runtime did not register with no rules, whatever the build ships', async () => { - const agents = new StructuredAgentRegistry([ - { - definition: CODEX_STRUCTURED_AGENT, - adapter: { ...fakeAdapter(), rewind: vi.fn(), recoverRewind: vi.fn() } - } - ]) - - expect(agents.definition(RECORD.provider)).toBeNull() - expect(agents.capabilities(RECORD.provider)).toBeNull() - const refused = pick(agents, RECORD, 'model') - await expect(refused.result).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } } - }) - expect(refused.persistOptions).not.toHaveBeenCalled() - const options = await readAtRest(agents) - expect(options.models).toEqual([]) - expect(options.current).toEqual({ model: 'pilot-model' }) - expect(options.conversationCommands).toEqual(['clear']) - expect(options.acceptsImages).toBe(false) - }) - - it('says whether the session takes images: the definition, narrowed by a running child', async () => { - const registry = (imagePrompts: boolean) => - new StructuredAgentRegistry([ - { - definition: { - ...NON_DEFAULT, - capabilities: { ...NON_DEFAULT.capabilities, imagePrompts } - }, - adapter: fakeAdapter() - } - ]) - const live = (acceptsImages?: boolean): StructuredAgentSessionLiveOptions => ({ - models: [], - current: {}, - ...(acceptsImages === undefined ? {} : { acceptsImages }) - }) - await expect(readAtRest(registry(true))).resolves.toMatchObject({ acceptsImages: true }) - await expect(readAtRest(registry(false))).resolves.toMatchObject({ acceptsImages: false }) - await expect(readAtRest(registry(true), live())).resolves.toMatchObject({ acceptsImages: true }) - await expect(readAtRest(registry(true), live(false))).resolves.toMatchObject({ - acceptsImages: false - }) - // A child's yes never widens a definition's no. - await expect(readAtRest(registry(false), live(true))).resolves.toMatchObject({ - acceptsImages: false - }) - }) - - it('refuses a pick for a session with no record, as before', async () => { - const agents = new StructuredAgentRegistry([ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() } - ]) - const missing = pick(agents, null, 'model') - await expect(missing.result).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect(missing.persistOptions).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts deleted file mode 100644 index 830fa9b812e..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -// What the host does with a second press of the same control, against the real host, store and -// journal: under the first press's id it answers from that press and does nothing more, which is -// why a client sends every press under a new id; under a new id it acts. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-thread-goal-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let setOption: Mock -let changeThreadGoal: Mock> -let stopBackgroundTasks: Mock> -let cancelTurn: Mock -let providerEvents: Parameters[0]['events'] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-repeat-press-')) - resetHostTestOperationIds() - setOption = vi.fn(async () => undefined) - changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) - cancelTurn = vi.fn(async () => ({ cancelled: true })) - store = await openTestAgentSessionRecordStore(root) - const adapter: StructuredAgentSessionAdapter = startsWhenPublished( - { - acquire: async ({ fence, spawnToken, events }) => { - providerEvents = events - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: fence > 1 ? ('resumed' as const) : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn, - answerPrompt: vi.fn(async () => undefined), - setOption, - changeThreadGoal, - stopBackgroundTasks - }, - () => host - ) - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexAgents(adapter), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - // One live background task, which the background Stops below reach. - statusSink: { - publish: () => {}, - forget: () => {}, - readChildWork: () => [ - { - id: 'child-task-1', - providerId: 'task-1', - kind: 'agent', - state: 'working', - membership: 'live', - firstObservedAt: 1, - observedAt: 1, - stoppable: true, - invocation: { invocationId: 'spawn-task-1', generation: 1 } - } - ] - }, - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -/** The provider's running turn, which a Stop naming it reaches. */ -async function runningTurn(): Promise { - providerEvents?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 }, - { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) -} - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function envelope( - method: string, - fields: Record, - clientOperationId: string, - expectedRuntimeFence: number | null = 1 -) { - return { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function pick(value: string, id = hostTestOperationId(), fence: number | null = 1) { - const fields = { key: 'model', value } - return host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields, id, fence), - ...fields - }) -} - -describe('a press sent again', () => { - it('leaves an option on the other pick under the first id, and applies it under a new one', async () => { - const first = hostTestOperationId() - await pick('A', first) - await pick('B') - - expect(await pick('A', first)).toMatchObject({ ok: true, replayed: true }) - expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B']) - expect(store.getRecord(SESSION)?.options?.model).toBe('B') - - expect(await pick('A')).toMatchObject({ ok: true, replayed: false }) - expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B', 'A']) - expect(store.getRecord(SESSION)?.options?.model).toBe('A') - }) - - it('leaves a cleared goal cleared under the first id, and sets it under a new one', async () => { - const change = (next: AgentSessionThreadGoalChange, id = hostTestOperationId()) => - host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change: next }, id), - change: next - }) - const set: AgentSessionThreadGoalChange = { kind: 'set', objective: 'Ship the parser' } - const first = hostTestOperationId() - await change(set, first) - await change({ kind: 'clear' }) - - expect(await change(set, first)).toMatchObject({ ok: true, replayed: true }) - expect(changeThreadGoal).toHaveBeenCalledTimes(2) - - expect(await change(set)).toMatchObject({ ok: true, replayed: false }) - expect(changeThreadGoal).toHaveBeenCalledTimes(3) - }) - - it('stops a background task only under a new id once the first Stop ran', async () => { - const fields = { turnId: 'background-tasks', scope: 'background-tasks', taskId: 'task-1' } - const stop = (id = hostTestOperationId()) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields, id), - turnId: 'background-tasks', - scope: 'background-tasks' as const, - taskId: 'task-1' - }) - // The first Stop reached the task before it could be stopped; its answer never arrived. - stopBackgroundTasks.mockResolvedValueOnce({ cancelled: false }) - const first = hostTestOperationId() - await stop(first) - - expect(await stop(first)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(stopBackgroundTasks).toHaveBeenCalledOnce() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(stopBackgroundTasks).toHaveBeenCalledTimes(2) - }) - - it('interrupts a named turn only under a new id once the first Stop ran', async () => { - const stop = (id = hostTestOperationId()) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }, id, null), - turnId: 'turn-1' - }) - await runningTurn() - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - const first = hostTestOperationId() - await stop(first) - - expect(await stop(first)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(cancelTurn).toHaveBeenCalledOnce() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledTimes(2) - }) - - it('keeps nothing of a press it turned away before running, so its own id runs next time', async () => { - const fields = { turnId: 'background-tasks', scope: 'background-tasks' } - const id = hostTestOperationId() - const stop = (fence: number) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields, id, fence), - turnId: 'background-tasks', - scope: 'background-tasks' as const - }) - await host.close(SESSION, 'evict') - - // No agent owns the chat, so the host turns the Stop away as not ready. - expect(await stop(1)).toMatchObject({ ok: false }) - const refused = await stop(1) - expect(refused.ok ? null : agentSessionRefusalOperationState(refused.refusal.code)).toBe( - 'pending-admission' - ) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - - expect(await stop(store.getRecord(SESSION)!.lease.runtimeFence)).toMatchObject({ - ok: true, - replayed: false, - value: { cancelled: true } - }) - expect(stopBackgroundTasks).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 21e35eebb9a..ad1cd60aef8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -43,6 +43,7 @@ export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS export const SWEEP_INTERVAL_MS = 5 export type RestTestAdapter = { + open: StructuredAgentSessionAdapter['open'] acquire: Mock closeSession: Mock> dispatch: Mock @@ -50,7 +51,6 @@ export type RestTestAdapter = { NonNullable > holdsDispatch: Mock> - startUnavailable: Mock> readOptions: Mock> } @@ -67,11 +67,16 @@ export type RestTestRig = { forget: Mock readChildWork: Mock<(subject: unknown) => AgentChildWorkView[]> } - /** Wraps an acquire so its child stays `starting`: every other child proves its start once + /** Wraps a spawn so its child stays `starting`: every other child proves its start once * published. */ heldStart: ( acquire: StructuredAgentSessionAdapter['acquire'] ) => StructuredAgentSessionAdapter['acquire'] + proveStart: (child: { + sessionId: string + fence: number + acquisitionGeneration: string + }) => Promise /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ restart: (deps?: Partial) => Promise dispose: () => Promise @@ -149,6 +154,9 @@ export async function createRestTestRig( } let store = await openTestAgentSessionRecordStore(root) const adapter: RestTestAdapter = { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async ({ fence, spawnToken }) => ({ acquisitionGeneration: `generation-${++generations}`, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, @@ -166,7 +174,6 @@ export async function createRestTestRig( dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), holdsDispatch: vi.fn(() => false), - startUnavailable: vi.fn(() => undefined), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } const starts = structuredAgentSessionHeldStarts(() => rig.host) @@ -201,6 +208,7 @@ export async function createRestTestRig( statusEvents, sink, heldStart: starts.held, + proveStart: starts.prove, restart: async (overrides = {}) => { await rig.host.flushAllStreamedEvents().catch(() => undefined) store = await openTestAgentSessionRecordStore(root) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 884655d8719..bece13e0aa0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -70,7 +70,6 @@ export function createStructuredAgentSessionRestartResume( getRecord: deps.store.getRecord, childWork: surfaces.readChildWork, childWorkOpen: surfaces.childWorkOpen, - startAnswered: (sessionId) => deps.adapter.startAnswered?.(sessionId), ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), teardownId: randomUUID(), now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts deleted file mode 100644 index c0e8ee04a55..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Startup restore has to publish status, not just index the session. -// -// A tab nobody reopens after a restart still owes the sidebar a row. The host restores such a -// session read-only, without a provider child, so the only thing that can surface its state is -// the status publication the restore wiring makes. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { - AgentSessionMutationEnvelope, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -const hosts: StructuredAgentSessionHost[] = [] -let root = '' - -function adapter(): StructuredAgentSessionAdapter { - return { - acquire: async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } -} - -function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { - const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: startsWhenPublished(adapter(), () => host), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'read does not need ownership' - }), - now: () => NOW - }) - hosts.push(host) - return host -} - -function sendEnvelope( - store: AgentSessionRecordStore, - fields: Record -): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields - }) - } -} - -/** Persists one turn, then hands back a restarted host over the same directories. */ -async function restartWithPersistedTurn(): Promise { - root = await mkdtemp(join(tmpdir(), 'orca-restart-status-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const host = createHost(store) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - const body = hostTestMessage('persisted conversation') - const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) - if (!sent.ok) { - throw new Error('send was refused') - } - // Delivered, not just accepted: a message still queued at the restart was never a request. - await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) - await host.flushAllStreamedEvents() - return createHost(await openTestAgentSessionRecordStore(root)) -} - -afterEach(async () => { - await Promise.all(hosts.splice(0).map((host) => host.flushAllStreamedEvents())) - await rm(root, { recursive: true, force: true }) - root = '' -}) - -describe('structured session restart status publication', () => { - // Served by the subscribe-time re-projection rather than the restore's own publish, so this - // covers what a restored journal projects — not the restore wiring. The test below pins that. - it('projects the persisted turn of a session restored without a provider', async () => { - const restarted = await restartWithPersistedTurn() - - await restarted.restoreReadableSessions() - const events: AgentSessionStatusEvent[] = [] - restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - - expect(events).toEqual([ - { - type: 'snapshot', - sessions: [ - expect.objectContaining({ - sessionId: SESSION, - workspaceId: 'workspace-1', - agent: 'codex', - status: 'idle', - latestPrompt: 'persisted conversation' - }) - ] - } - ]) - }) - - it('publishes a restored session to a list already sitting on the stream', async () => { - const restarted = await restartWithPersistedTurn() - const events: AgentSessionStatusEvent[] = [] - restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - expect(events).toEqual([{ type: 'snapshot', sessions: [] }]) - - await restarted.restoreReadableSessions() - - // The restore wiring publishes; without it this list never hears about the session at all. - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts index 11d23efa804..d0309cc890e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts @@ -5,7 +5,12 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from './structured-agent-session-host-test-data' +import { CALLER } from './structured-agent-session-host-test-harness' import { StructuredAgentRegistry } from './structured-agent-registry' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' @@ -70,3 +75,21 @@ it('lists a recorded failure of such a chat as not retryable', async () => { { sessionId: marker!.sessionId, retryable: false } ]) }) + +it('keeps such a chat readable and refuses to start its agent', async () => { + const { host, store, acquire } = await interruptedRestart( + undefined, + undefined, + undefined, + codexOnAnotherTransport() + ) + const fence = store.getRecord(SESSION)!.lease.runtimeFence + + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)!.lease.runtimeFence).toBe(fence) + expect(await host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ ok: true }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts index 07b0a547c92..7032e17c02b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts @@ -21,7 +21,6 @@ function witnessed() { AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, childWorkOpen: () => false, childWork: () => undefined, - startAnswered: () => undefined, capsule: { record: async (markers) => void recorded.push([...markers]) }, teardownId: 'teardown', now: () => 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index bf3fe842e0a..4453a250cce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -34,7 +34,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { getRecord: (sessionId: string) => AgentSessionRecord | null childWork: WorkingAtStopInput['childWork'] childWorkOpen: WorkingAtStopInput['childWorkOpen'] - startAnswered: NonNullable capsule?: Pick teardownId: string now: () => number @@ -75,7 +74,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { getRecord: deps.getRecord, childWork: deps.childWork, childWorkOpen: deps.childWorkOpen, - startAnswered: deps.startAnswered, trigger, teardownId: deps.teardownId, now: deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index 3987d0f5dae..c93cd47480a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -49,11 +49,13 @@ function adapter(): StructuredAgentSessionAdapter { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async (input) => { acquires += 1 sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts index 4155b96a4fe..c26807a7f1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts @@ -52,11 +52,13 @@ function adapter(): StructuredAgentSessionAdapter { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async (input) => { acquires += 1 sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 258cf5c4112..22e34b4a712 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -67,11 +67,13 @@ beforeEach(async () => { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async (input) => { acquires.push(input) sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires.length, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts index 742f53d84da..5503c8c25ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts @@ -1,7 +1,6 @@ -// A send's carried model and effort against the real host, store and journal: kept with the -// message and applied as it is handed over (recorded before a start, written to a running child), -// skipped when the chat already holds it, never stopping the message, part of the operation's -// fingerprint, and kept on a queued card until the card goes out. +// A send's carried choices against the real host, store and journal: kept with the message and +// applied when its agent starts or is handed it, part of the operation's fingerprint, and kept on +// a card Orca keeps. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' @@ -40,20 +39,6 @@ beforeEach(async () => { afterEach(() => rig.dispose()) -function recordedOptions(): Readonly> | undefined { - return rig.store.getRecord(SESSION)?.options -} - -async function handedOver(id: string): Promise { - await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) -} - -/** A running child with nothing in flight. */ -async function idleRunningChild(): Promise { - const working = await rig.workingSend() - await rig.settleAccepted(working, 'idle') -} - describe('a send carrying its model and effort to a chat at rest', () => { it('records them before the start its message needs, so the agent starts on them', async () => { const rest = await createRestTestRig() @@ -107,13 +92,11 @@ describe('an opening message created with a model and effort', () => { expect(rest.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') ) const proveStart = async () => - rest.host.handleAdapterEvent({ - type: 'started', + rest.proveStart({ sessionId: SESSION, fence: rest.store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: (await rest.adapter.acquire.mock.results[0]!.value) - .acquisitionGeneration, - reportedOptions: options + .acquisitionGeneration }) return { first, proveStart } } @@ -134,14 +117,10 @@ describe('an opening message created with a model and effort', () => { }) } const picked = rest.host.setOption(REST_TEST_CALLER, { envelope, ...fields }) - const ranOn: (string | undefined)[] = [] - rest.adapter.dispatch.mockImplementationOnce(async () => { - ranOn.push(rest.store.getRecord(SESSION)?.options?.model) - return acceptedDispatch() - }) + rest.adapter.dispatch.mockImplementationOnce(async () => acceptedDispatch()) await proveStart() await vi.waitFor(() => expect(rest.adapter.dispatch).toHaveBeenCalledOnce()) - expect(ranOn).toEqual(['opus']) + expect(rest.adapter.dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ model: 'opus' }) expect(await picked).toMatchObject({ ok: true }) expect(rest.store.getRecord(SESSION)?.options?.model).toBe('sonnet') } finally { @@ -168,65 +147,6 @@ describe('an opening message created with a model and effort', () => { }) describe('a send carrying its model and effort', () => { - it('writes them to a running child as it is handed over, model first, skipping what the chat holds', async () => { - await idleRunningChild() - const first = rig.send('switch', undefined, { carried: PICKED }) - await first.result - await handedOver(first.id) - expect(vi.mocked(rig.setOption).mock.calls.map(([input]) => input.key)).toEqual([ - 'model', - 'effort' - ]) - expect(recordedOptions()).toMatchObject({ effort: 'high' }) - await rig.settleAccepted(first.id, 'switch') - - vi.mocked(rig.setOption).mockClear() - const again = rig.send('same again', undefined, { carried: { effort: 'high' } }) - await again.result - await handedOver(again.id) - expect(rig.setOption).not.toHaveBeenCalled() - }) - - it("runs an accepted message on its own selection, though a later pick changed the chat's", async () => { - const starting = await createQueuedMessageTestRig({ starting: true }) - try { - const sent = starting.send('mine', undefined, { carried: { model: 'gpt-a' } }) - expect(await sent.result).toMatchObject({ ok: true }) - await eventually(() => expect(starting.starts).toHaveBeenCalled()) - // Picked while the message waits for its agent to start. - const fields = { key: 'model', value: 'gpt-b' } - const picked = starting.host.setOption(CALLER, { - envelope: starting.envelope(fields, 'agentSession.setOption', hostTestOperationId()), - ...fields - }) - const setOption = vi.mocked(starting.setOption) - starting.dispatch.mockImplementationOnce(async () => { - expect(setOption.mock.calls.at(-1)?.[0]).toMatchObject({ key: 'model', value: 'gpt-a' }) - expect(setOption.mock.invocationCallOrder.at(-1)).toBeLessThan( - starting.dispatch.mock.invocationCallOrder.at(-1) ?? 0 - ) - return acceptedDispatch() - }) - await starting.proveStart() - await eventually(async () => - expect((await starting.submission(sent.id))?.handedOverAt).toBeDefined() - ) - expect(await picked).toMatchObject({ ok: true }) - expect(starting.store.getRecord(SESSION)?.options?.model).toBe('gpt-b') - } finally { - await starting.dispose() - } - }) - - it('still sends when the write fails, on what the chat holds', async () => { - await idleRunningChild() - vi.mocked(rig.setOption).mockRejectedValueOnce(new Error('child refused')) - const { id, result } = rig.send('anyway', undefined, { carried: { model: 'gpt-picked' } }) - expect(await result).toMatchObject({ ok: true }) - await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) - expect(recordedOptions()?.model).toBeUndefined() - }) - it('fingerprints them with the operation, so the same id with other options conflicts', async () => { const body = hostTestMessage('once') const clientOperationId = hostTestOperationId() @@ -246,42 +166,6 @@ describe('a send carrying its model and effort', () => { }) }) -describe('a queued send carrying its model and effort', () => { - async function queued(carried: Readonly>): Promise<{ - working: string - draftId: string - }> { - const working = await rig.workingSend() - const answer = await rig.send('later', 'queue-if-active', { carried }).result - if (!answer.ok || !('queued' in answer.value)) { - throw new Error('expected a queued receipt') - } - return { working, draftId: answer.value.queued.messageId } - } - - it('keeps them on the card and applies them when the queue sends it', async () => { - const { working, draftId } = await queued({ model: 'gpt-picked' }) - expect(rig.setOption).not.toHaveBeenCalled() - await rig.settleAccepted(working, 'a') - await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) - await eventually(() => - expect(rig.setOption).toHaveBeenCalledWith( - expect.objectContaining({ key: 'model', value: 'gpt-picked' }) - ) - ) - }) - - it('applies them when the card is sent by hand', async () => { - const { working, draftId } = await queued({ model: 'gpt-picked' }) - expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'a') - await handedOver(await rig.handoffId(draftId)) - expect(rig.setOption).toHaveBeenCalledWith( - expect.objectContaining({ key: 'model', value: 'gpt-picked' }) - ) - }) -}) - describe('a send kept as a card because Orca stopped before handing it over', () => { it('keeps its model and effort on the card', async () => { const kept = await createQueuedMessageTestRig({ restartable: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts deleted file mode 100644 index a97271a580a..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ /dev/null @@ -1,700 +0,0 @@ -// A send, or a hold, that finds the session's provider child gone, against the real host. The -// send is accepted at once; its delivery restarts the child, or rejects it with the reason. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionMutationEnvelope, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let spawnChild: StructuredAgentSessionAdapter['acquire'] -let dispatch: Mock -let hostErrors: unknown[] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-send-recovery-')) - resetHostTestOperationIds() - hostErrors = [] - let generation = 0 - spawnChild = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${++generation}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - }) - acquire = vi.fn(spawnChild) - dispatch = vi.fn(async () => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${dispatch.mock.calls.length}`, - ordinal: dispatch.mock.calls.length - } - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: { - warn: (_message, fields) => hostErrors.push(fields.error), - error: (_message, fields) => hostErrors.push(fields.error) - }, - store, - adapter: startsWhenPublished( - { - acquire, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function sendParams(text: string, operationId = hostTestOperationId()) { - const body = hostTestMessage(text) - const envelope: AgentSessionMutationEnvelope = { - sessionId: SESSION, - clientOperationId: operationId, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - } - return { envelope, body } -} - -/** Accepted at once, before any owner exists for it; answers the message id. */ -async function accept(params: ReturnType): Promise { - const result = await host.send(CALLER, params) - expect(result, JSON.stringify(result)).toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending', handoverRecorded: true } } - }) - return params.envelope.clientOperationId -} - -async function submission(clientMessageId: string) { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - ) -} - -/** The submission once delivery is done with it: handed over, or rejected unwritten. */ -async function settled(clientMessageId: string) { - await eventually(async () => { - const current = await submission(clientMessageId) - expect(current?.dispatchState !== 'pending' || current.handedOverAt !== undefined).toBe(true) - }) - return submission(clientMessageId) -} - -/** The failure rows a start the chat needed left, oldest first. */ -async function errorStatuses(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) -} - -/** The child timed out or exited: its lease is handed back and the host holds no session. */ -async function loseOwner(): Promise { - await host.close(SESSION, 'evict') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - acquire.mockClear() -} - -describe('a send with no live owner', () => { - it('restarts the owner once and delivers against it', async () => { - await loseOwner() - - await accept(sendParams('after the child died')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('accepts the send before anything restarts, and the restart hands it over', async () => { - await loseOwner() - const order: string[] = [] - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - order.push('acquire') - return spawnChild(input) - }) - const admit = store.admitMutationOperation - vi.spyOn(store, 'admitMutationOperation').mockImplementation((args) => { - order.push('admit') - return admit(args) - }) - - await accept(sendParams('accept first')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - - expect(order).toEqual(['acquire']) - }) - - it('leaves a live owner alone', async () => { - acquire.mockClear() - - await accept(sendParams('owner is live')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).not.toHaveBeenCalled() - }) - - it('restarts an independently retained old-build clear source', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - conversationCommand: { - command: 'clear', - state: 'completed', - replacementSessionId: 'session-after-clear', - operationId: hostTestOperationId(), - callerKey: CALLER.callerKey, - phase: 'committed' - } - })) - - await expect(host.send(CALLER, sendParams('into the retained chat'))).resolves.toMatchObject({ - ok: true - }) - await eventually(() => expect(acquire).toHaveBeenCalledOnce()) - }) - - it('restarts an owner that exited while the session stayed readable', async () => { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence, - acquisitionGeneration: 'generation-1' - }) - expect(host.hasSession(SESSION)).toBe(true) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - - await accept(sendParams('after an exit')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('restarts nothing for a resend the journal already answers', async () => { - const params = sendParams('sent once') - await accept(params) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - // The child died during startup: the lease is handed back, the fence moves, and the session - // stays readable. The client resends against the new fence. - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'startup deadline', - cause: 'unexpected-exit', - fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1', - startupUnproven: true - }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - const resent = { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - } - } - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, replayed: true }) - - expect(acquire).not.toHaveBeenCalled() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - - // Retry rotates the id: a genuinely new send restarts the owner once. - await accept(sendParams('sent once')) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() - }) - - /** A row admitted for this send, then the host died before the journal write, left as `outcome` - * says: `pending` by this build, `unknown` by a build that marked it before running. */ - async function admittedThenHostDied( - params: ReturnType, - outcome: 'pending' | 'unknown' - ) { - await store.admitMutationOperation({ - callerKey: CALLER.callerKey, - envelope: params.envelope, - hostFingerprint: params.envelope.payloadFingerprint, - now: NOW, - operationIdScope: 'global' - }) - if (outcome === 'unknown') { - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'unknown' } - }) - } - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1' - }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - return { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - } - } - } - - it('runs a send admitted but never run for the first time, restarting the owner once', async () => { - const resent = await admittedThenHostDied(sendParams('admitted, then the host died'), 'pending') - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending' } } - }) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('accepts afresh when only an older ledger row exists without a command receipt', async () => { - const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown') - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending' } } - }) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('accepts a send that arrives while a restart holds the queue, and hands both over in order', async () => { - await loseOwner() - const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - let claimed = () => {} - let release = () => {} - const claim = new Promise((resolve) => (claimed = resolve)) - const spawn = new Promise((resolve) => (release = resolve)) - const spawnChild = acquire.getMockImplementation() - acquire.mockImplementationOnce(async (input) => { - claimed() - await spawn - return spawnChild!(input) - }) - - const first = await accept(sendParams('first')) - await claim - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(lostFence + 1) - // Written against the lost owner's fence, which admits it: a send is a conversation write. - const late = sendParams('second') - late.envelope.expectedRuntimeFence = lostFence - const second = host.send(CALLER, late) - release() - - expect(await second).toMatchObject({ ok: true }) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([ - first, - late.envelope.clientOperationId - ]) - }) - - it('shares one restart between concurrent sends', async () => { - await loseOwner() - - const results = await Promise.all([ - host.send(CALLER, sendParams('first')), - host.send(CALLER, sendParams('second')), - host.send(CALLER, sendParams('third')) - ]) - - expect(results.map((result) => result.ok)).toEqual([true, true, true]) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(3)) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('replays into a closed session without spawning anything', async () => { - const params = sendParams('sent once') - await accept(params) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - await loseOwner() - - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - - // The conversation was opened for the answer; the record's lease was left as it was. - expect(host.hasSession(SESSION)).toBe(true) - expect(acquire).not.toHaveBeenCalled() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) - const params = sendParams('while signed out') - // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit - // was observed, so the chat does not say the provider stopped. - const cause = "Codex couldn't restart. Send your message to try again." - - const id = await accept(params) - - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: cause, - rejection: { kind: 'restartFailed' } - }) - expect(dispatch).not.toHaveBeenCalled() - // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. - expect( - store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) - ).toMatchObject({ outcome: { status: 'succeeded' } }) - // One row, in the error tone, so the reason outlives the error strip. - expect(await errorStatuses()).toEqual([cause]) - }) - - it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { - await loseOwner() - const said = `no rollout found for thread id ${THREAD}` - acquire.mockRejectedValue( - new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) - ) - const id = await accept(sendParams('after the thread went away')) - - // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. - const rejection = { - kind: 'restartFailed', - detail: { text: said, audience: 'person' }, - refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } - } - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: "Codex couldn't restart. Send your message to try again.", - rejection - }) - expect(await errorStatuses()).toEqual([ - "Codex couldn't restart. Send your message to try again." - ]) - // Orca's own text is logged once where the start failed. - expect( - hostErrors.filter( - (error) => error instanceof Error && error.message === `thread/resume failed: ${said}` - ) - ).toHaveLength(1) - }) - - it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) - const params = sendParams('while signed out') - await settled(await accept(params)) - expect(acquire).toHaveBeenCalledTimes(1) - - // A client that resends the same id gets the recorded rejection, and the chat no second row. - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true, - value: { submission: { dispatchState: 'rejected' } } - }) - expect(acquire).toHaveBeenCalledTimes(1) - expect(await errorStatuses()).toHaveLength(1) - - // The outbox's Retry rotates the id: a fresh attempt, with its own row. - expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ - dispatchState: 'rejected' - }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(await errorStatuses()).toHaveLength(2) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('restarts and delivers a later send once the cause clears', async () => { - await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) - expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ - dispatchState: 'rejected' - }) - - // The user signed in; nothing about the failed attempt is remembered. - await accept(sendParams('signed in now')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('suggests a new chat only when this host has nothing to restart the chat from', async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) - const failed = await settled(await accept(sendParams('restart fails'))) - expect(failed).toMatchObject({ dispatchState: 'rejected' }) - expect(failed?.reason).not.toMatch(/new chat/) - - // The adapter cannot run this record where it lives: no retry would bring it back. - host.deps.adapter.supportsLocation = () => false - const unresumable = await settled(await accept(sendParams('cannot resume here'))) - - expect(unresumable).toMatchObject({ - dispatchState: 'rejected', - reason: "Codex couldn't restart. Start a new chat to continue.", - rejection: { - kind: 'restartFailed', - refusal: { - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' } - } - } - }) - }) - - it('rejects the message with the cause when the restart met a lease someone else is settling', async () => { - await loseOwner() - vi.spyOn( - host['conversationDelivery'].loop['deps'], - 'ensureProviderChild' - ).mockResolvedValueOnce({ - ok: false, - refusal: { - code: 'execution_owner_reconciling', - message: 'Another runtime is still adjudicating this lease.' - } - }) - - const id = await accept(sendParams('owner being settled')) - - // The refusal's prose stays out of the chat; its code rides in the fact. - const cause = "Codex couldn't restart. Send your message to try again." - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: cause, - rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } - }) - expect(acquire).not.toHaveBeenCalled() - expect(await errorStatuses()).toEqual([cause]) - }) - - it('rejects the message, and reports the fault, when the restart itself faults', async () => { - await loseOwner() - vi.spyOn( - host['conversationDelivery'].loop['deps'], - 'ensureProviderChild' - ).mockRejectedValueOnce(new Error('spawn-token mint failed')) - - const id = await accept(sendParams('bookkeeping failed')) - - // Orca's own fault: reported to the log, and the chat says only that Orca failed. - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: "Orca ran into a problem, so this didn't go through. Try again.", - rejection: { kind: 'hostFault' } - }) - expect(hostErrors).toContainEqual( - expect.objectContaining({ message: 'spawn-token mint failed' }) - ) - expect(await errorStatuses()).toHaveLength(1) - }) - - it('exits the recovery stage a failed attempt latched before its delivery resumes it', async () => { - await loseOwner() - // What an acquisition whose exit could not be proven leaves behind: nobody's, but latched. - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'recovering' } - })) - host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - - await accept(sendParams('latched owner')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: null, - claimStatus: 'live' - }) - }) - - it('adjudicates a lease this host has not reconciled before its delivery resumes it', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - - // The send's start is the same serialized resume a hold runs, reconciliation first. - await accept(sendParams('owner unverified')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'live' - }) - }) -}) - -// A pane keeps the fence of the last frame it read. An idle release and the restart after it -// each move the lease, so that fence can be several generations behind the one a write lands on. -describe('a write fenced to an owner the pane has not seen replaced', () => { - it('delivers a send fenced to the owner an idle release retired', async () => { - const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await loseOwner() - const params = sendParams('after the release') - params.envelope.expectedRuntimeFence = seenFence - - const id = await accept(params) - expect(await settled(id)).toMatchObject({ dispatchState: 'accepted' }) - - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) - }) - - // Clients resend a refused message when the fence they hold moves. A failed start is a - // rejected message now, never a refused send, and the pane keeps the fence it subscribed under. - it("keeps the pane's fence on the rows a failed start publishes, and rejects the message", async () => { - const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const frames: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) - await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) - const subscribed = frames.length - - const id = await accept(sendParams('while signed out')) - - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - rejection: { kind: 'restartFailed' } - }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) - const published = frames.slice(subscribed) - expect(published.length).toBeGreaterThan(0) - for (const frame of published) { - expect(frame).toMatchObject({ fence: seenFence }) - } - }) - - it('admits a Stop and a send queued behind the cold start that replaced their owner', async () => { - await loseOwner() - const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - let claimed = () => {} - let release = () => {} - const claim = new Promise((resolve) => (claimed = resolve)) - const spawn = new Promise((resolve) => (release = resolve)) - acquire.mockImplementationOnce(async (input) => { - claimed() - await spawn - return spawnChild(input) - }) - - const firstParams = sendParams('starts the agent') - const first = host.send(CALLER, firstParams) - await claim - const cancelFields = { turnId: 'turn-1' } - const stop = host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: lostFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields: cancelFields - }) - }, - ...cancelFields - }) - const late = sendParams('typed during the start') - late.envelope.expectedRuntimeFence = lostFence - 1 - const second = host.send(CALLER, late) - release() - - expect(await first).toMatchObject({ ok: true }) - expect(await stop).toMatchObject({ ok: true, replayed: false }) - expect(await second).toMatchObject({ ok: true, replayed: false }) - // The Stop withdrew the message its start held; the one typed after it is delivered. - expect(await settled(late.envelope.clientOperationId)).toMatchObject({ - dispatchState: 'accepted' - }) - expect(await submission(firstParams.envelope.clientOperationId)).toMatchObject({ - dispatchState: 'rejected' - }) - // The Stop ended the child still proving its start, so the late send started another. - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index ffde62ac8d7..504e8abbee4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -21,7 +21,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { operationDidNotRun, type ReadyOperationSelection -} from './structured-agent-session-ready-operation' +} from './structured-agent-session-operation-child' import { AGENT_SESSION_NOT_ATTACHED, type AgentSessionMutationSessionPreparation diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 87cd7ae2263..015c6607a54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -44,6 +45,7 @@ const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost +const starts = structuredAgentSessionHeldStarts(() => host) let acquire: Mock let dispatch: Mock let generation = 0 @@ -98,11 +100,7 @@ function currentChild() { } function proveStarted(): Promise { - return host.handleAdapterEvent({ - type: 'started', - ...currentChild(), - reportedOptions: { model: 'sonnet' } - }) + return starts.prove(currentChild()) } function exitBeforeProof(): Promise { @@ -111,8 +109,7 @@ function exitBeforeProof(): Promise { ...currentChild(), reason: EXIT_REASON, failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } @@ -138,7 +135,6 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -156,15 +152,18 @@ beforeEach(async () => { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire, + adapter: starts.wrap({ + acquire: starts.held(acquire), releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + setOption: vi.fn(async () => undefined), + open: async () => ({ + reportedOptions: { model: '' } + }) + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, @@ -176,6 +175,7 @@ beforeEach(async () => { }) afterEach(async () => { + starts.clear() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index 228bf2b66cd..c1b72500c61 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -25,13 +25,15 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let dispatch: Mock +let setOption: Mock beforeEach(() => { - ;({ store, host, dispatch } = hostTestState()) + ;({ store, host, dispatch, setOption } = hostTestState()) }) function hostJournal(): AgentSessionJournal { @@ -57,6 +59,65 @@ async function delivered(clientMessageId: string, options: { handedOver?: true } } describe('send', () => { + it('passes each accepted message choice to its provider dispatch and keeps replay immutable', async () => { + await attach() + const first = hostTestMessage('first') + const second = hostTestMessage('second') + const aOptions = { model: 'model-a', effort: 'high' } + const bOptions = { model: 'model-b', effort: 'low' } + const a = { + envelope: envelope('agentSession.send', { body: first, options: aOptions }), + body: first, + options: aOptions + } + const b = { + envelope: envelope('agentSession.send', { body: second, options: bOptions }), + body: second, + options: bOptions + } + await host.send(CALLER, a) + await delivered(a.envelope.clientOperationId) + await host.send(CALLER, b) + await delivered(b.envelope.clientOperationId) + + expect(dispatch.mock.calls.map(([input]) => input.executionOptions)).toEqual([ + aOptions, + bOptions + ]) + expect(dispatch).toHaveBeenCalledTimes(2) + expect( + hostJournal() + .submissions() + .map((submission) => submission.options) + ).toEqual([aOptions, bOptions]) + await expect(host.send(CALLER, a)).resolves.toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(setOption).not.toHaveBeenCalled() + }) + + it('settles a refused message choice without dispatching its prompt', async () => { + await attach() + dispatch.mockRejectedValueOnce(new AgentSessionOptionRejectedError('model unavailable')) + const body = hostTestMessage('do not send') + const options = { model: 'model-unavailable' } + const params = { + envelope: envelope('agentSession.send', { body, options }), + body, + options + } + const result = await host.send(CALLER, params) + expect(result).toMatchObject({ ok: true }) + await vi.waitFor(() => { + expect( + hostJournal() + .submissions() + .find((submission) => submission.clientMessageId === params.envelope.clientOperationId) + ).toMatchObject({ dispatchState: 'rejected' }) + }) + expect(dispatch).toHaveBeenCalledOnce() + expect(setOption).not.toHaveBeenCalled() + }) + it('writes the submission before dispatching and resolves it accepted', async () => { await attach() const body = hostTestMessage('add a retry') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts deleted file mode 100644 index 4377cf7b484..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -// What a client's retry sees around a failed attach settlement: a crash between -// reserve and settlement replays into the original reservation, and a settled -// failure refuses sends without ever re-dispatching on the user's behalf. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -const publishFault = vi.hoisted(() => ({ failOnPublish: 0, publishCount: 0 })) - -vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - writeAgentSessionStoreRows: (...args: Parameters) => { - publishFault.publishCount += 1 - if (publishFault.publishCount === publishFault.failOnPublish) { - throw new Error('simulated crash before failed-settlement publish') - } - return actual.writeAgentSessionStoreRows(...args) - } - } -}) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let releaseAcquisition: Mock> -let dispatch: Mock - -function accepted(): AgentSessionDispatchOutcome { - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - } -} - -function adapter(): StructuredAgentSessionAdapter { - return startsWhenPublished( - { - acquire, - releaseAcquisition, - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ) -} - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-settled-attach-retry-')) - publishFault.failOnPublish = 0 - publishFault.publishCount = 0 - resetHostTestOperationIds() - acquire = vi.fn(async ({ fence }) => ({ - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - releaseAcquisition = vi.fn(async () => true) - dispatch = vi.fn(async () => accepted()) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - publishFault.failOnPublish = 0 - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -/** The host starting the agent with no message to deliver, as an operation that needs it does. */ -function startAgent(): Promise { - return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) -} - -describe('settled attach retry', () => { - it('settles a post-acquisition journal failure and retries without a restart', async () => { - const journalDatabase = openTestJournalHostDatabase(root) - vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( - new Error('journal path unavailable') - ) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase, - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - const first = hostTestAttachParams(null) - - await expect(host.attach(CALLER, first)).rejects.toThrow('journal path unavailable') - expect(releaseAcquisition).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - reservedSpawnToken: null, - runtimeFence: 2 - }) - expect( - store.listOperationRows().find((row) => row.operationId === first.envelope.clientOperationId) - ?.outcome - ).toMatchObject({ status: 'failed' }) - - await expect(host.attach(CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: null, - runtimeFence: 3 - }) - }) - - it('continues a safe same-host pending replay with the reserved token', async () => { - const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { - spawnTokens.push(spawnToken) - if (spawnTokens.length === 1) { - throw new Error('reply lost') - } - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - const mintSpawnToken = vi.fn(() => 'spawn-safe') - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - now: () => NOW - }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 - - await expect(host.attach(CALLER, params)).rejects.toThrow( - 'agent session acquisition failure settlement failed' - ) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) - expect(mintSpawnToken).toHaveBeenCalledOnce() - expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) - }) - - it('releases a reservation a crash left ownerless at restart, so the next start goes ahead', async () => { - const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { - spawnTokens.push(spawnToken) - if (spawnTokens.length === 1) { - throw new Error('reply lost') - } - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - let token = 0 - const mintSpawnToken = vi.fn(() => `spawn-${++token}`) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - // A host that cannot read another process's environment, so no scan can prove anything. - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'spawn token scan unavailable' - }), - now: () => NOW - }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 - - await expect(host.attach(CALLER, params)).rejects.toThrow( - 'agent session acquisition failure settlement failed' - ) - expect(publishFault.publishCount).toBe(2) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - runtimeFence: 1, - reservedSpawnToken: 'spawn-1', - ownerProcess: null - }) - - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - // A host that cannot read another process's environment, so no scan can prove anything. - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'spawn token scan unavailable' - }), - now: () => NOW - }) - - await host.restoreReadableSessions() - expect(releaseAcquisition).toHaveBeenCalledTimes(1) - // No owner was recorded: released at restart, with no evidence, since nothing proved one. - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - runtimeFence: 2, - reservedSpawnToken: null, - ownerProcess: null, - deathEvidence: null - }) - - // The interrupted operation's own retry continues it as a fresh reservation. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) - expect(mintSpawnToken).toHaveBeenCalledTimes(2) - expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeKind: 'native', - runtimeFence: 3, - handoffStage: null, - handoffOperationId: null, - ownerProcess: { spawnToken: 'spawn-2' } - }) - }) - - it('settles a submission the host restart left pending, and never redelivers it', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) - // Admitted: written to the child, acknowledgement still outstanding. The - // restart below is the process fact that ends the wait, not a stopwatch. - dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) - const body = hostTestMessage('written before the host died') - const unknownParams = { - envelope: envelope('agentSession.send', { body }), - body - } - const first = await host.send(CALLER, unknownParams) - expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // Handed over before the host dies: that is what makes the restart's answer doubt. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-restarted', - probeOwner: async () => ({ outcome: 'pid-absent' }), - now: () => NOW - }) - await host.restoreReadableSessions() - await startAgent() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: null, - runtimeFence: 3 - }) - expect(dispatch).toHaveBeenCalledTimes(1) - - const newBody = hostTestMessage('are you there?') - const sent = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body: newBody }), - body: newBody - }) - if (!sent.ok) { - throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) - } - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) - const restoredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) - if (!restoredHistory.ok) { - throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) - } - expect( - restoredHistory.page.submissions.find( - (submission) => submission.clientMessageId === unknownParams.envelope.clientOperationId - )?.dispatchState - ).toBe('unknown') - - // A restart ends the wait without proving the dead child never took the - // frame, so even an explicit retry replays rather than sending a second copy. - const explicitRetry = await host.send(CALLER, { - ...unknownParams, - envelope: { - ...unknownParams.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 3 - }, - retryUnknown: true - }) - expect(explicitRetry).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'unknown' } } - }) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('records proven acquisition cleanup as durable death evidence', async () => { - acquire.mockRejectedValueOnce(new Error('resume rejected')) - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: false, - refusal: { - message: "Codex couldn't restart. Send your message to try again.", - ownerVerdict: 'exited' - } - }) - - expect(releaseAcquisition).toHaveBeenCalledTimes(1) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { - kind: 'exit-observed', - detail: 'acquisition cleanup proved no provider child remains' - } - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts new file mode 100644 index 00000000000..9a9f2a00389 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts @@ -0,0 +1,507 @@ +// A chat's agent starts outside the session's serialize: each wait a start makes is held in turn +// here, against the real host, store and journal with a scripted adapter. While it is held, a send +// is accepted at once, a Stop or quit ends the start without waiting on it, and what was sent is +// handed over once, in order, to the child the start published. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { runWithReadyStructuredAgentSessionOperation } from './structured-agent-session-ready-operation' +import { persistRewindRecord } from './structured-rewind-recovery' + +const CALLER = { callerKey: 'client-1' } +const PID_ABSENT: AgentSessionOwnerProbe = { outcome: 'pid-absent' } +const eventually = (assertion: () => void | Promise) => + vi.waitFor(assertion, { timeout: 10_000 }) + +type Point = + | 'closing' + | 'probe' + | 'reprobe' + | 'resolver' + | 'spawn' + | 'drain' + | 'open' + | 'recovery' + | 'pending-row' + | 'dispatch' +const holds = new Map>() +const reached = new Set() + +/** Each hold stops the first start step that reaches it, once. */ +async function at(point: Point): Promise { + const held = holds.get(point) + if (held) { + holds.delete(point) + reached.add(point) + await held.promise + } +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let closeSession: Mock> +let releaseAcquisition: Mock> +let probeOwner: Mock<(record: unknown) => Promise> +let delivered: string[] +let events: StructuredAgentSessionEventSink | undefined +let initialAttach: ReturnType +let idleSweep: { intervalMs: number; idleMs: number } + +function startHost(): void { + host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + acquire, + closeSession, + releaseAcquisition, + open: async () => { + await at('open') + return { reportedOptions: { model: 'default' } } + }, + dispatch: async (input) => { + await at('dispatch') + input.signal?.throwIfAborted() + delivered.push(input.clientMessageId) + const ordinal = delivered.length + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: `t-${ordinal}`, ordinal } + } + }, + recoverRewind: async () => { + await at('recovery') + return { ok: true, items: [] } + }, + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + probeOwner, + stopOwnerProcess: vi.fn(), + resolveLaunchArgs: async () => { + await at('resolver') + return undefined + }, + idleSweep, + now: () => NOW + }) + // The attempt's sink drains before its child is published. + const { runtimeState } = host.collaboratorsForTests() + const mint = runtimeState.mintEventSink.bind(runtimeState) + vi.spyOn(runtimeState, 'mintEventSink').mockImplementation((sessionId) => { + const sink = mint(sessionId) + const drained = sink.drained.bind(sink) + sink.drained = async () => { + await at('drain') + return drained() + } + return sink + }) +} + +/** A new host with no child, so the next send starts one; `reopen` loads the store anew, so its + * lease is unreconciled. */ +async function restart(reopen = false): Promise { + await host.close(SESSION, 'evict') + await host.flushAllStreamedEvents() + if (reopen) { + store = await openTestAgentSessionRecordStore(root) + } + startHost() +} + +/** How each point is reached: everything before its hold has already happened. */ +const SETUP: Record Promise> = { + closing: async () => { + // A stop whose close stayed unproven leaves the child closing; the next start joins it. + closeSession.mockResolvedValueOnce(false) + const { serialize, lifetime } = host.collaboratorsForTests() + await serialize(SESSION, () => lifetime.stopAgent(SESSION, { cause: 'evict', resting: true })) + .then(() => undefined) + .catch(() => undefined) + closeSession.mockImplementationOnce(async () => { + await at('closing') + return true + }) + }, + probe: () => restart(true).then(() => holdProbes([PID_ABSENT], 0)), + reprobe: async () => { + await leaveRecovering() + holdProbes([{ outcome: 'identity-matched', matchedOn: ['spawn-token'] }, PID_ABSENT], 1) + }, + resolver: () => restart(), + spawn: () => restart(), + drain: () => restart(), + open: () => restart(), + recovery: async () => { + // A rewind in doubt over a turn the chat holds, so the start asks the provider about it. + const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + events!.appendItem(turn, hostTestMessage('rewound'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await host.flushStreamedEvents(SESSION) + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await restart() + await persistRewindRecord(store, SESSION, store.getRecord(SESSION)!.lease.runtimeFence, { + operationId: 'rewind-op', + callerKey: 'client-1', + itemId: agentJournalItemKey(turn), + providerItemId: agentJournalItemKey(turn), + expectedEpoch, + phase: 'prepared' + }) + }, + 'pending-row': async () => { + await restart() + const write = AgentSessionJournal.prototype.resolveDispatch + vi.spyOn(AgentSessionJournal.prototype, 'resolveDispatch').mockImplementation(async function ( + this: AgentSessionJournal, + ...args + ) { + const written = await write.apply(this, args) + if (args[0].state === 'pending') { + await at('pending-row') + } + return written + }) + }, + dispatch: () => restart() +} + +/** The lease as a failed attempt leaves it: still naming its owner, in the recovering stage. */ +async function leaveRecovering(): Promise { + const owner = store.getRecord(SESSION)!.lease.ownerProcess + await restart() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, ownerProcess: owner, handoffStage: 'recovering' } + })) +} + +/** Answers the owner probes in turn; the one at `heldAt` waits on the probe hold for its point. */ +function holdProbes(answers: AgentSessionOwnerProbe[], heldAt: number): void { + answers.forEach((answer, index) => + probeOwner.mockImplementationOnce(async () => { + if (index === heldAt) { + await at(heldAt === 0 ? 'probe' : 'reprobe') + } + return answer + }) + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-start-admission-')) + resetHostTestOperationIds() + holds.clear() + reached.clear() + delivered = [] + idleSweep = { intervalMs: 60 * 60_000, idleMs: 60 * 60_000 } + acquire = vi.fn(async (input) => { + events = input.events + await at('spawn') + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: input.spawnToken }, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + link: { + linkId: `link-${input.fence}`, + handle: codexProviderHandle(THREAD), + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: input.fence, + observedAt: NOW + } + } + }) + closeSession = vi.fn(async () => true) + releaseAcquisition = vi.fn(async () => true) + probeOwner = vi.fn(async () => PID_ABSENT) + store = await openTestAgentSessionRecordStore(root) + startHost() + initialAttach = hostTestAttachParams(null) + expect(await host.attach(CALLER, initialAttach)).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + for (const held of holds.values()) { + held.resolve() + } + vi.restoreAllMocks() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function envelope(method: string, fields: Record) { + const clientOperationId = hostTestOperationId() + const payloadFingerprint = computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + return { sessionId: SESSION, clientOperationId, expectedRuntimeFence: null, payloadFingerprint } +} + +function send(text: string) { + const fields = { body: hostTestMessage(text) } + const sent = envelope('agentSession.send', fields) + return { id: sent.clientOperationId, sent: host.send(CALLER, { envelope: sent, ...fields }) } +} + +async function submission(id: string) { + const { submissions } = await host.journalSnapshot(SESSION) + return submissions.find((entry) => entry.clientMessageId === id) +} + +/** A Stop naming no turn: it stops whatever the chat has in flight, a start included. */ +const stop = () => host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + +/** Holds `point`, sends the first message and waits for its start to reach the hold. */ +async function heldAt(point: Point) { + await SETUP[point]() + const held = Promise.withResolvers() + holds.set(point, held) + const spawned = acquire.mock.calls.length + const first = send('first') + await eventually(() => expect(reached.has(point)).toBe(true)) + return { first, release: () => held.resolve(), spawned } +} + +const child = () => host.collaboratorsForTests().sessions.get(SESSION)?.child ?? null +const PRE_PUBLISH: readonly Point[] = ['probe', 'reprobe', 'resolver', 'spawn', 'drain'] +// The owner probe's signal and re-probe are POSIX-only: Windows never signals a saved pid. +const POSIX = process.platform !== 'win32' + +describe('a send while the agent start is held', () => { + it.each<{ point: Point; acceptedWhileHeld?: false }>([ + { point: 'closing' }, + { point: 'probe' }, + ...(POSIX ? [{ point: 'reprobe' as const }] : []), + { point: 'resolver' }, + { point: 'spawn' }, + { point: 'drain' }, + { point: 'open' }, + // A send waits on a rewind in doubt by design; it waits outside the lock, behind the start. + { point: 'recovery', acceptedWhileHeld: false }, + { point: 'dispatch' } + ])('at $point is accepted at once and handed over once, in order', async (row) => { + const { first, release, spawned } = await heldAt(row.point) + + const second = send('second') + if (row.acceptedWhileHeld !== false) { + expect(await first.sent).toMatchObject({ ok: true }) + expect(await second.sent).toMatchObject({ ok: true }) + expect(delivered).toEqual([]) + } + release() + + await eventually(async () => + expect((await submission(second.id))?.dispatchState).toBe('accepted') + ) + expect(delivered).toEqual([first.id, second.id]) + expect(acquire).toHaveBeenCalledTimes(spawned + 1) + expect(child()).toMatchObject({ phase: 'ready', generation: `generation-${spawned + 1}` }) + }) +}) + +describe('a Stop while the agent start is held', () => { + it.each([ + // The first await after the start registers: nothing is probed, resolved or spawned yet. + 'probe', + ...(POSIX ? ['reprobe' as const] : []), + 'resolver', + 'spawn', + 'drain', + 'open', + 'recovery', + 'pending-row', + 'dispatch' + ])('at %s is recorded without waiting, and nothing reaches the provider', async (point) => { + const { first, release } = await heldAt(point) + + const stopped = stop() + // The pending row is a commit, which the Stop's own step queues behind; its abort does not. + if (point !== 'pending-row') { + expect(await stopped).toMatchObject({ ok: true }) + } + release() + expect(await stopped).toMatchObject({ ok: true }) + + await eventually(() => + expect(host.collaboratorsForTests().runtimeState.startFlights.get(SESSION)).toBeUndefined() + ) + if (point === 'recovery') { + // A send waiting on a rewind in doubt was never accepted: the Stop ended what it waited on. + expect(await first.sent).toMatchObject({ ok: false }) + } else { + await eventually(async () => + expect(await submission(first.id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + ) + } + expect(delivered).toEqual([]) + // A child spawned for the stopped start is closed, never published as the session's. + if (PRE_PUBLISH.includes(point)) { + expect(child()).toBeNull() + } + if (point === 'spawn' || point === 'drain') { + await eventually(() => expect(releaseAcquisition).toHaveBeenCalled()) + } + }) +}) + +describe('a start racing the host', () => { + it('honors Stop admitted while preparation yields, then starts later work once', async () => { + await restart() + const { loop } = host.collaboratorsForTests().conversationDelivery + const preparation = Promise.withResolvers() + const entered = Promise.withResolvers() + vi.spyOn(loop['deps'], 'retireOutgrownChild').mockImplementationOnce(async () => { + entered.resolve() + await preparation.promise + }) + const spawned = acquire.mock.calls.length + const first = send('withdraw before claim') + await entered.promise + expect(host.collaboratorsForTests().runtimeState.startFlights.has(SESSION)).toBe(false) + const stopped = stop() + preparation.resolve() + expect(await stopped).toMatchObject({ ok: true }) + await eventually(async () => + expect((await submission(first.id))?.dispatchState).toBe('rejected') + ) + await eventually(() => + expect(host.collaboratorsForTests().runtimeState.startFlights.has(SESSION)).toBe(false) + ) + expect(acquire).toHaveBeenCalledTimes(spawned) + expect(child()).toBeNull() + const later = send('new demand') + await eventually(async () => + expect((await submission(later.id))?.dispatchState).toBe('accepted') + ) + expect(delivered).toEqual([later.id]) + expect(acquire).toHaveBeenCalledTimes(spawned + 1) + }) + + it('replays eager attach through the unpublished flight without inventing readiness', async () => { + const { first, release, spawned } = await heldAt('drain') + await first.sent + expect(child()).toBeNull() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + const opened = Promise.withResolvers() + holds.set('open', opened) + let operationRan = false + const operation = runWithReadyStructuredAgentSessionOperation( + host['mutationContext'](), + CALLER.callerKey, + envelope('agentSession.setOption', {}), + { method: 'agentSession.setOption', fields: {} }, + async () => { + operationRan = true + return { + ok: true, + replayed: false, + fence: child()!.fence, + cursor: host.collaboratorsForTests().sessions.get(SESSION)!.journal.cursor(), + value: {} + } + } + ) + let replayed = false + const replay = host.attach(CALLER, initialAttach).then((result) => { + replayed = true + return result + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + expect(replayed).toBe(false) + expect(child()).toBeNull() + release() + expect(await replay).toMatchObject({ ok: true, replayed: true }) + expect(child()).toMatchObject({ phase: 'starting', generation: `generation-${spawned + 1}` }) + expect(operationRan).toBe(false) + opened.resolve() + expect(await operation).toMatchObject({ ok: true }) + expect(operationRan).toBe(true) + await eventually(async () => + expect((await submission(first.id))?.dispatchState).toBe('accepted') + ) + expect(child()).toMatchObject({ phase: 'ready', generation: `generation-${spawned + 1}` }) + expect(acquire).toHaveBeenCalledTimes(spawned + 1) + }) + + it('spawns nothing when quit lands before the spawn', async () => { + const { first, release, spawned } = await heldAt('resolver') + await first.sent + + await host.flushAllStreamedEvents() + release() + + expect(acquire).toHaveBeenCalledTimes(spawned) + expect(child()).toBeNull() + }) + + it('starts exactly one child for sends made at once', async () => { + await restart() + const spawned = acquire.mock.calls.length + const sends = ['a', 'b', 'c'].map(send) + + await eventually(async () => + expect((await submission(sends[2]!.id))?.dispatchState).toBe('accepted') + ) + expect(acquire).toHaveBeenCalledTimes(spawned + 1) + expect(delivered).toEqual(sends.map(({ id }) => id)) + }) + + it('clears a lease a failed attempt left recovering, and the start goes ahead', async () => { + await leaveRecovering() + + const { id } = send('after a failed start') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBeNull() + }) + + it('dispatches a send an idle-sweep close races, without deadlock', async () => { + idleSweep = { intervalMs: 5, idleMs: 0 } + const { first, release } = await heldAt('spawn') + await first.sent + await new Promise((resolve) => setTimeout(resolve, 30)) + release() + + await eventually(async () => + expect((await submission(first.id))?.dispatchState).toBe('accepted') + ) + expect(delivered).toEqual([first.id]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts new file mode 100644 index 00000000000..0812ae0bb0a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts @@ -0,0 +1,85 @@ +// Whether a session's record may start its agent on this host, read in the commit after the +// lease is adjudicated and recovery concluded, so it sees the lease the resolver handed back. + +import { + refuse, + type AgentSessionRefusalDetailsByCode, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire-refusals' +import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import { hostCanStartRecord } from './structured-agent-session-provider-support' +import { + structuredAgentSessionResumeOperationId, + structuredAgentSessionResumeParams +} from './structured-agent-session-resume-eligibility' + +/** The eligibility read, in the commit after recovery: the params the attach acts on, or why the + * record may not start here. */ +export function structuredAgentSessionStartParams( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor: string | undefined +): + | Extract + | { + ok: true + params: NonNullable> + startupOptions?: Readonly> + } { + const record = context.deps.store.getRecord(sessionId) + if (!record) { + return refusedStructuredAgentSessionStart( + 'agent_session_identity_required', + { reason: 'recordMissing' }, + 'No structured session exists by that id.' + ) + } + if (!hostCanStartRecord(context.deps, record)) { + return refusedStructuredAgentSessionStart( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot resume the requested structured agent session.' + ) + } + const params = structuredAgentSessionResumeParams( + record, + structuredAgentSessionResumeOperationId(context.now()), + context.sessions.get(sessionId)?.lastEndedChild + ) + if (!params) { + return record.lease.unreconciled + ? refusedStructuredAgentSessionStart( + 'execution_owner_reconciling', + { reason: 'hostReconciling' }, + 'This host has not yet adjudicated the session lease.' + ) + : record.lease.claimStatus === 'conflicted' + ? refusedStructuredAgentSessionStart( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + terminalOwnerRefusalMessage(record.lease) + ) + : refusedStructuredAgentSessionStart( + 'agent_session_ownership_unknown', + { reason: 'notResumable' }, + 'The session lease is not one this host may resume.' + ) + } + const startupOptions = startedFor + ? context.sessions + .get(sessionId) + ?.journal.submissions() + .find((submission) => submission.clientMessageId === startedFor)?.options + : undefined + return { ok: true, params, ...(startupOptions ? { startupOptions } : {}) } +} + +export function refusedStructuredAgentSessionStart( + code: C, + details: NoInfer, + message: string +): Extract { + return { ok: false, refusal: refuse(code, details, message) } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index c4f08c6a71c..4c7e0d271cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,5 +1,3 @@ -import { failedProviderChildStart } from './structured-agent-session-provider-child' -import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' @@ -13,6 +11,30 @@ import type { import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { + AgentJournalCursor, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { failedProviderChildStart } from './structured-agent-session-provider-child' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' + +/** A queued message whose child died while it waited inherits that child's failure. */ +export function startThatFailedWhileQueued( + session: StructuredAgentSessionHostSession, + oldest: AgentJournalSubmission +): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { + const ended = failedProviderChildStart(session) + if ( + !ended || + oldest.acceptedSequence === undefined || + ended.endedAt.epoch !== session.journal.cursor().epoch || + ended.endedAt.sequence < oldest.acceptedSequence + ) { + return null + } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null +} export function refusedStructuredAgentSessionStart( record: AgentSessionRecord | null, @@ -78,11 +100,19 @@ export function hasStructuredAgentSessionStartFailureRow( */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, - failure: StructuredAgentSessionStartFailure + failure: StructuredAgentSessionStartFailure, + /** Only these of what is queued: the rest was accepted after the start was asked for. */ + which?: (submission: AgentJournalSubmission) => boolean ): Promise { const oldest = oldestQueuedSubmission(session) const startKey = failure.startKey ?? oldest?.clientMessageId - if (startKey === undefined) { + const rejection = { reason: failure.reason, rejection: failure.rejection } + const chosen = which + ? session.journal + .submissions() + .filter((entry) => isQueuedAgentJournalSubmission(entry) && which(entry)) + : null + if (startKey === undefined || chosen?.length === 0) { return } await session.journal.appendLifecycleBatch({ @@ -90,10 +120,33 @@ export async function recordStructuredAgentSessionStartFailure( fence: session.fence, recovered: true, mutations: [structuredAgentSessionStartFailureRow(startKey, failure)], - ...(oldest ? { rejectsQueued: { reason: failure.reason, rejection: failure.rejection } } : {}) + ...(chosen + ? { + dispatches: chosen.map(({ clientMessageId }) => ({ + clientMessageId, + fence: session.fence, + state: 'rejected' as const, + recovered: true as const, + ...rejection + })) + } + : oldest + ? { rejectsQueued: rejection } + : {}) }) } +/** Accepted no later than `cursor`, in the journal epoch it names. */ +export function acceptedNoLaterThan( + journal: Pick, + cursor: AgentJournalCursor +): (submission: AgentJournalSubmission) => boolean { + return (submission) => + journal.cursor().epoch === cursor.epoch && + submission.acceptedSequence !== undefined && + submission.acceptedSequence <= cursor.sequence +} + export function oldestQueuedSubmission( session: Pick ): ReturnType[number] | undefined { @@ -108,22 +161,3 @@ export function oldestQueuedSubmission( } return oldest } - -/** A start that died while this message waited on it — a view's, say — is the message's failed - * start: settled with it, under its key, rather than started again into the same failure. */ -export function startThatFailedWhileQueued( - session: StructuredAgentSessionHostSession, - oldest: NonNullable> -): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { - const ended = failedProviderChildStart(session) - if ( - !ended || - oldest.acceptedSequence === undefined || - ended.endedAt.epoch !== session.journal.cursor().epoch || - ended.endedAt.sequence < oldest.acceptedSequence - ) { - return null - } - const cause = structuredAgentSessionEndedChildFailure(ended) - return cause ? { startKey: ended.generation, cause } : null -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 3bfa2112f3f..3bc7b80506b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -57,8 +57,7 @@ function exitBeforeProof(): Promise { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } @@ -118,9 +117,9 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: () => new Promise(() => {}), acquire: vi.fn(async ({ fence, spawnToken }) => { const child = { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -144,8 +143,7 @@ beforeEach(async () => { fence, acquisitionGeneration: child.acquisitionGeneration, reason: EXIT_REASON, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } return child diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts new file mode 100644 index 00000000000..3747bf6e774 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts @@ -0,0 +1,104 @@ +// A session's agent start in flight, outside its serialize. +// +// The serialize is where messages are admitted, so a start that held it for its probes, resolvers, +// spawn and drains made every send wait for the agent. A start instead registers here, in one short +// step before its first wait; every wait after runs outside the lock, raced with the abort signal a +// Stop, close or quit reaches it by, and every write is a commit that first re-checks the start is +// still the session's. A start that loses that check is late: what it spawned is closed, never +// handed anything. + +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' +import type { StructuredAgentSessionAttachLock } from './structured-agent-session-attach-lock' +import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' + +export type StructuredAgentSessionStartFlight = { + /** The start's generation: the startup attempt it mints carries the same id. */ + readonly abort: () => void + readonly attemptId: string + /** Settles once the start is over: its child published, refused, or aborted. */ + readonly outcome: Promise +} + +/** One start per session: a second asker joins the one in flight. */ +export class StructuredAgentSessionStartFlights { + private readonly flying = new Map() + /** Exits reported for a start's child before its publish put it on record. */ + private readonly exits = new Map() + + get(sessionId: string): StructuredAgentSessionStartFlight | undefined { + return this.flying.get(sessionId) + } + + has(sessionId: string): boolean { + return this.flying.has(sessionId) + } + + isCurrent(sessionId: string, attemptId: string): boolean { + return this.flying.get(sessionId)?.attemptId === attemptId + } + + register(sessionId: string, flight: StructuredAgentSessionStartFlight): void { + this.flying.set(sessionId, flight) + } + + end(sessionId: string, attemptId: string): void { + if (this.isCurrent(sessionId, attemptId)) { + this.flying.delete(sessionId) + this.exits.delete(sessionId) + } + } + + /** Held for the publish when a start is in flight; false when none is. */ + deferExit(event: StructuredAgentSessionEndedEvent): boolean { + if (!this.flying.has(event.sessionId)) { + return false + } + this.exits.set(event.sessionId, [...(this.exits.get(event.sessionId) ?? []), event]) + return true + } + + /** The exit reported for the child a start is publishing, if any. */ + takeExit( + sessionId: string, + child: { generation: string | null; fence: number } + ): StructuredAgentSessionEndedEvent | undefined { + const exits = this.exits.get(sessionId) ?? [] + this.exits.delete(sessionId) + return exits.find( + (exit) => exit.fence === child.fence && exit.acquisitionGeneration === child.generation + ) + } +} + +/** The start was not the session's by the time it committed: its result is discarded. */ +export class StructuredAgentSessionLateStartError extends Error { + constructor() { + super('the agent start was superseded before it committed') + this.name = 'StructuredAgentSessionLateStartError' + } +} + +/** The lock a start runs its attach under; its fence is the start's own registration. */ +export function structuredAgentSessionStartFlightLock(input: { + sessionId: string + attemptId: string + signal: AbortSignal + flights: StructuredAgentSessionStartFlights + serialize: (sessionId: string, task: () => Promise) => Promise +}): StructuredAgentSessionAttachLock { + const { sessionId, attemptId, signal } = input + return { + signal, + wait: (work) => waitForPromiseWithSignal(work, signal), + commit: (step) => + input.serialize(sessionId, async () => { + // A close of the conversation, like a Stop or quit, aborts the start first. + throwIfSignalAborted(signal) + if (!input.flights.isCurrent(sessionId, attemptId)) { + throw new StructuredAgentSessionLateStartError() + } + return step() + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index 4d3c509f98f..41d52b29606 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -11,15 +11,12 @@ import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child- import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' -/** A spawned child with no stdout frame or stderr line for this long has stopped starting. */ -export const STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS = 60_000 -/** No start, however chatty, outlives this from its spawn. */ +/** No start outlives this from its registration; each protocol step has its own shorter bound. */ export const STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS = 10 * 60_000 -export type StructuredAgentSessionStartupLimits = { silenceMs: number; ceilingMs: number } +export type StructuredAgentSessionStartupLimits = { ceilingMs: number } export const STRUCTURED_AGENT_SESSION_STARTUP_LIMITS: StructuredAgentSessionStartupLimits = { - silenceMs: STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS, ceilingMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } @@ -35,16 +32,26 @@ export type StructuredAgentSessionStartupAttempt = { readonly options?: Readonly> /** The permission mode the start launches with; absent for an agent with none. */ readonly permissionMode?: AgentChatPermissionMode - /** Provider events may begin before acquisition returns. */ + /** Provider events may begin before spawn returns. */ readonly events?: StructuredAgentSessionEventSink /** Background work this start's child reports, scoped to the attempt. Unset until the host routes * child work by attempt; adapters report through their registration until then. */ readonly childWork?: (evidence: AgentChildWorkEvidence[]) => void /** Aborted by a close, a Stop admitted now, quit, or the startup limit: the adapter stops what it - * started and the acquire fails. */ + * started and spawn fails. */ readonly signal?: AbortSignal - /** Any stdout frame or stderr line from the child: proof the start is still moving. */ - readonly onOutput?: () => void +} + +/** The startup limit's own reason, so an expired start is told from a close, Stop or quit. */ +export class StructuredAgentSessionStartupExpiredError extends Error { + constructor() { + super('the agent stopped making progress before it finished starting') + this.name = 'StructuredAgentSessionStartupExpiredError' + } +} + +export function isStructuredAgentSessionStartupExpired(reason: unknown): boolean { + return reason instanceof Error && reason.name === 'StructuredAgentSessionStartupExpiredError' } export type StructuredAgentSessionAcquisitionReceipt = Pick< diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index 5c45da77127..a3f90ef4346 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -12,6 +12,8 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../../shared/native-chat-stopped-before-start' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -48,10 +50,18 @@ let closeSession: Mock let startupLimits: Partial | undefined const providerStarted = vi.fn() +const recordLiveListing = vi.fn() +const openGates = new Map void>() + +async function proveOpen(model = 'default'): Promise { + const generation = `generation-${acquire.mock.calls.length}` + await eventually(() => expect(openGates.has(generation)).toBe(true)) + openGates.get(generation)!(model) + openGates.delete(generation) +} function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -69,7 +79,7 @@ function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcq } } -/** An acquire that publishes at spawn, as every one does: the child proves its start later. */ +/** A spawn that publishes its child before open proves readiness. */ const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => acquisition(input) async function startHost(): Promise { @@ -78,6 +88,10 @@ async function startHost(): Promise { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: ({ acquisitionGeneration }) => + new Promise((resolve) => { + openGates.set(acquisitionGeneration!, (model) => resolve({ reportedOptions: { model } })) + }), acquire, dispatch, closeSession, @@ -88,8 +102,8 @@ async function startHost(): Promise { }, modelCatalog: { read: vi.fn(async () => ({ origin: 'unknown' as const })), + recordLiveListing, retainedLiveModelIds: vi.fn(async () => []), - recordLiveListing: vi.fn(), prewarm: vi.fn(async () => {}), stop: vi.fn(), providerStarted @@ -120,6 +134,8 @@ beforeEach(async () => { resetHostTestOperationIds() startupLimits = undefined providerStarted.mockReset() + recordLiveListing.mockReset() + openGates.clear() acquire = vi.fn(async (input) => acquisition(input)) dispatch = vi.fn(async () => ({ state: 'accepted' as const, @@ -178,16 +194,6 @@ async function submission(id: string): Promise { const id = await accept(text) @@ -221,7 +227,7 @@ const HOST_STOPPED = agentSessionFailureWords(agentSessionFailureFact('hostStopp }) describe('a send while the agent starts', () => { - it('is accepted at once and handed over only once the start proves itself', async () => { + it('keeps a spawned child unopened until required protocol open proves readiness', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') @@ -229,7 +235,7 @@ describe('a send while the agent starts', () => { expect((await submission(id))?.handedOverAt).toBeUndefined() expect(dispatch).not.toHaveBeenCalled() - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(dispatch).toHaveBeenCalledTimes(1) @@ -242,37 +248,19 @@ describe('a send while the agent starts', () => { // The same id again is the same message: it is recorded and handed over once. await accept('first', first) - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, second]) }) - it('ignores a start proof from a child it no longer holds', async () => { - await restartWith(publishFirst) - const id = await heldBehindStart('hello') - - await host.handleAdapterEvent(startedEvent('generation-stale')) - await host.handleAdapterEvent({ ...startedEvent(), fence: startedEvent().fence + 1 }) - await host.flushStreamedEvents(SESSION) - - expect(dispatch).not.toHaveBeenCalled() - expect((await host.readStatusSummary(SESSION))?.hostExecutionPhase).toBe('starting') - await host.handleAdapterEvent(startedEvent()) - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - }) - it('tells the model catalog once the start proves itself, never for a child it no longer holds', async () => { await restartWith(publishFirst) providerStarted.mockClear() const id = await heldBehindStart('hello') expect(providerStarted).not.toHaveBeenCalled() - await host.handleAdapterEvent(startedEvent('generation-stale')) - await host.flushStreamedEvents(SESSION) - expect(providerStarted).not.toHaveBeenCalled() - - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(providerStarted).toHaveBeenCalledTimes(1) expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) @@ -290,8 +278,7 @@ describe('a start that ends before it proves itself', () => { fence: store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reason: 'exited with code 1', - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) @@ -312,6 +299,14 @@ describe('a start that ends before it proves itself', () => { }) expect((await submission(id))?.handedOverAt).toBeUndefined() expect(closeSession).toHaveBeenCalled() + // The taken-back send carries the Stop's feedback; a host row would say it twice. + const snapshot = await host.journalSnapshot(SESSION) + expect( + projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions) + ).toMatchObject([ + { role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { blocks: [{ type: 'text', text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT }] } + ]) expect(dispatch).not.toHaveBeenCalled() expect(startupAttemptOpen()).toBe(false) }) @@ -332,10 +327,13 @@ describe('a start that ends before it proves itself', () => { expect(startupAttemptOpen()).toBe(false) }) - it('stops a published start that goes silent and fails what it held', async () => { - startupLimits = { silenceMs: 50 } + it('stops a published start that never readies at the ceiling, not before, and fails what it held', async () => { + startupLimits = { ceilingMs: 400 } await restartWith(publishFirst) const id = await heldBehindStart('hello') + await vi.advanceTimersByTimeAsync(200) + expect(closeSession).not.toHaveBeenCalled() + expect(startupAttemptOpen()).toBe(true) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected'), 50) expect(await submission(id)).toMatchObject(HOST_STOPPED) @@ -345,33 +343,8 @@ describe('a start that ends before it proves itself', () => { expect(startupAttemptOpen()).toBe(false) }) - it('keeps a slow start that is still talking, and stops one that never readies at the ceiling', async () => { - startupLimits = { silenceMs: 50, ceilingMs: 400 } - const chatter: ReturnType[] = [] - await restartWith(async (input) => { - await input.onSpawned?.(acquisition(input).process) - chatter.push(setInterval(() => input.onOutput?.(), 10)) - return acquisition(input) - }) - try { - const id = await heldBehindStart('hello') - await vi.advanceTimersByTimeAsync(200) - expect(closeSession).not.toHaveBeenCalled() - expect(startupAttemptOpen()).toBe(true) - - await eventually( - async () => expect((await submission(id))?.dispatchState).toBe('rejected'), - 50 - ) - expect(await submission(id)).toMatchObject(HOST_STOPPED) - expect(dispatch).not.toHaveBeenCalled() - } finally { - chatter.forEach(clearInterval) - } - }) - - it('aborts an acquire still in its handshake once it goes silent and fails what it held', async () => { - startupLimits = { silenceMs: 50 } + it('aborts an acquire still in its handshake at the ceiling and fails what it held', async () => { + startupLimits = { ceilingMs: 50 } await restartWith( (input) => new Promise((_resolve, reject) => { @@ -409,11 +382,11 @@ describe('a start that ends before it proves itself', () => { }) it('is not ended by the limit once it proved itself', async () => { - startupLimits = { silenceMs: 50 } + startupLimits = { ceilingMs: 50 } await restartWith(publishFirst) - const id = await heldBehindStart('hello', 0) - await host.handleAdapterEvent(startedEvent()) - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted'), 0) + const id = await heldBehindStart('hello') + await proveOpen() + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await vi.advanceTimersByTimeAsync(100) @@ -427,11 +400,13 @@ describe('what a started child reports', () => { it('keeps provider reports out of the saved next-message choice', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) + await proveOpen('reported') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await host.handleAdapterEvent({ - ...startedEvent(), type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reportedOptions: { model: 'from-settings' } }) await host.flushStreamedEvents(SESSION) @@ -447,16 +422,37 @@ describe('what a started child reports', () => { options: { model: 'picked' }, now: NOW }) - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) + await proveOpen('reported') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await host.handleAdapterEvent({ - ...startedEvent(), type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reportedOptions: { model: 'from-settings' } }) await host.flushStreamedEvents(SESSION) expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) }) + + it('saves a settings readback that arrives before open proves the start', async () => { + await restartWith(publishFirst) + await heldBehindStart('hello') + const listing = { models: [], configuredDefault: { modelId: 'opus' } } + // Claude's settings readback can land while the host still holds the child as starting. + const reported = host.handleAdapterEvent({ + type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reportedOptions: { model: 'opus' }, + catalogListing: listing + }) + await proveOpen() + await reported + + await eventually(() => expect(recordLiveListing).toHaveBeenCalledWith(SESSION, listing)) + }) }) function startupAttemptOpen(): boolean { @@ -464,7 +460,7 @@ function startupAttemptOpen(): boolean { } describe('the attempt an acquire runs under', () => { - it('is minted by the host with the lease, the pinned launch and its progress report', async () => { + it('is minted by the host with the lease and the pinned launch', async () => { await restartWith(publishFirst) await heldBehindStart('hello') @@ -474,7 +470,6 @@ describe('the attempt an acquire runs under', () => { fence: record.lease.runtimeFence, launch: { location: record.location, accountHome: record.accountHome } }) - expect(input.onOutput).toEqual(expect.any(Function)) expect(input.attemptId).toEqual(expect.any(String)) expect(input.signal).toBeInstanceOf(AbortSignal) }) @@ -491,7 +486,7 @@ describe('a chat after startup permission backfill', () => { await restartWith(publishFirst) await heldBehindStart('hello') expect(acquire.mock.calls.at(-1)![0].permissionMode).toBe(mode) - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await vi.advanceTimersByTimeAsync(50) await host.flushStreamedEvents(SESSION) expect(store.getRecord(SESSION)?.permissionMode).toBe(mode) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 13bd46a60d3..f2eb584eb08 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts @@ -1,4 +1,3 @@ -import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' // Every agent this build registers starts under the host's one startup attempt and its hold on // delivery. Iterates the runtime's real registration list, so an agent registered later is covered // without being named here. @@ -33,13 +32,6 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l const CALLER = { callerKey: 'client-1' } -// Temporary, removed when every adapter publishes at spawn: adapters whose own acquire already does, -// with the test that drives the real one. Every other registration still runs its handshake inside -// acquire (the bridge) until then. -const REAL_ADAPTER_PUBLISHES_AT_SPAWN: Readonly> = { - claude: 'structured-agent-session-claude-hung-start-stop.test.ts' -} - let root: string | null = null afterEach(async () => { @@ -65,7 +57,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ store: AgentSessionRecordStore acquire: Mock dispatch: Mock - opened: PromiseWithResolvers + ready: () => void }> { root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) resetHostTestOperationIds() @@ -79,7 +71,6 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ spawnToken: input.spawnToken }, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - providerChildPhase: 'starting' as const, link: { linkId: `link-${input.fence}`, handle: handle(definition), @@ -92,9 +83,12 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ const dispatch: Mock = vi.fn(async () => ({ state: 'admitted' as const })) - const opened = Promise.withResolvers() + let ready = () => {} const adapter: StructuredAgentSessionAdapter = { - open: () => opened.promise, + open: () => + new Promise((resolve) => { + ready = () => resolve({ reportedOptions: {} }) + }), acquire, dispatch, closeSession: vi.fn(async () => true), @@ -118,7 +112,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW }) - return { host, store, acquire, dispatch, opened } + return { host, store, acquire, dispatch, ready: () => ready() } } describe.each( @@ -127,7 +121,7 @@ describe.each( ) )('%s starts under the host attempt', (agent, definition) => { it('acquires under a host-minted attempt and is handed nothing until it proves its start', async () => { - const { host, store, acquire, dispatch, opened } = await hostFor(definition) + const { host, store, acquire, dispatch, ready } = await hostFor(definition) try { const attached = await host.attach( CALLER, @@ -144,8 +138,7 @@ describe.each( expect(acquire.mock.calls[0]?.[0]).toMatchObject({ attemptId: expect.any(String), fence: record.lease.runtimeFence, - launch: { location: record.location, accountHome: record.accountHome }, - onOutput: expect.any(Function) + launch: { location: record.location, accountHome: record.accountHome } }) const body = hostTestMessage('hello') @@ -168,9 +161,7 @@ describe.each( ) expect(dispatch).not.toHaveBeenCalled() - opened.resolve({ - reportedOptions: { model: 'default' } - }) + ready() await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) } finally { @@ -178,11 +169,3 @@ describe.each( } }) }) - -describe.each( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent).filter( - (agent) => !REAL_ADAPTER_PUBLISHES_AT_SPAWN[agent] - ) -)('%s with its own adapter', () => { - it.todo('publishes at spawn instead of finishing its handshake first') -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts deleted file mode 100644 index 841b3582558..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS as CEILING_MS, - STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS as SILENCE_MS -} from './structured-agent-session-startup-attempt-contract' -import { - StructuredAgentSessionStartupAttempts, - mintStructuredAgentSessionStartupAttempt, - type StructuredAgentSessionExpiredStartup, - type StructuredAgentSessionStartupSettled -} from './structured-agent-session-startup-attempt' - -const SESSION = 'session-alpha' -const CHILD = { generation: 'generation-1', fence: 3 } - -let expired: StructuredAgentSessionExpiredStartup[] -let settled: StructuredAgentSessionStartupSettled[] -let attempts: StructuredAgentSessionStartupAttempts - -function mint() { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mint reads only the lease fence, location, account home and options this literal sets. - const record = { - lease: { runtimeFence: CHILD.fence }, - location: { executionHostId: 'local', workspaceId: 'workspace-1' }, - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' } - } as unknown as AgentSessionRecord - return mintStructuredAgentSessionStartupAttempt({ - record, - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: null - }, - spawnToken: 'spawn-1' - }) -} - -/** Output every `everyMs` for `forMs`. */ -function chatter(output: () => void, forMs: number, everyMs = 10_000): void { - for (let elapsed = 0; elapsed < forMs; elapsed += everyMs) { - vi.advanceTimersByTime(everyMs) - output() - } -} - -beforeEach(() => { - vi.useFakeTimers() - expired = [] - settled = [] - attempts = new StructuredAgentSessionStartupAttempts({ - expire: (startup) => expired.push(startup), - settled: (startup) => settled.push(startup) - }) -}) - -afterEach(() => { - attempts.dispose() - vi.useRealTimers() -}) - -describe('the startup clock', () => { - it('does not run before the spawn: resolving a launch is not starting', () => { - attempts.track(SESSION, mint()) - - vi.advanceTimersByTime(CEILING_MS * 2) - - expect(expired).toEqual([]) - }) - - it('fails a spawned agent that says nothing for the silence limit, naming the acquire', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - - vi.advanceTimersByTime(SILENCE_MS - 1) - expect(expired).toEqual([]) - vi.advanceTimersByTime(1) - - expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: null }]) - vi.advanceTimersByTime(CEILING_MS) - expect(expired).toHaveLength(1) - // Measured at the limit, recorded once the aborted acquire settles. - expect(settled).toEqual([]) - attempts.abandon(SESSION, attempt.attemptId) - expect(settled).toEqual([{ agent: 'codex', outcome: 'silent', durationMs: SILENCE_MS }]) - }) - - it('records a start that turned ready as the limit fired as ready', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - vi.advanceTimersByTime(SILENCE_MS) - expect(expired).toHaveLength(1) - - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'ready' }) - - expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS }]) - }) - - it('keeps a slow start that is still talking past the silence limit', () => { - const attempt = mint() - const progress = attempts.track(SESSION, attempt) - progress.spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - chatter(progress.output, SILENCE_MS * 3) - attempts.ready(SESSION, CHILD) - - expect(expired).toEqual([]) - expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS * 3 }]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) - - it('times silence from the last output, not the spawn', () => { - const progress = attempts.track(SESSION, mint()) - progress.spawned() - vi.advanceTimersByTime(SILENCE_MS - 1_000) - progress.output() - - vi.advanceTimersByTime(SILENCE_MS - 1) - expect(expired).toEqual([]) - vi.advanceTimersByTime(1) - - expect(expired).toHaveLength(1) - }) - - it('fails a chatty agent that never becomes ready at the ceiling', () => { - const attempt = mint() - const progress = attempts.track(SESSION, attempt) - progress.spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - chatter(progress.output, CEILING_MS - 10_000) - expect(expired).toEqual([]) - chatter(progress.output, 10_000) - - expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: CHILD }]) - expect(settled).toEqual([{ agent: 'codex', outcome: 'ceiling', durationMs: CEILING_MS }]) - }) - - it('starts on output or publication when no spawn was reported', () => { - const byOutput = mint() - attempts.track(SESSION, byOutput).output() - const byPublish = mint() - attempts.track('session-beta', byPublish) - attempts.published('session-beta', byPublish.attemptId, { ...CHILD, phase: 'starting' }) - - vi.advanceTimersByTime(SILENCE_MS) - - expect(expired.map((startup) => startup.attemptId).sort()).toEqual( - [byOutput.attemptId, byPublish.attemptId].sort() - ) - }) -}) - -describe('an attempt ends', () => { - it('with the start its child proved, and ignores a stale child’s proof', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - attempts.ready(SESSION, { ...CHILD, generation: 'generation-0' }) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.ready(SESSION, CHILD) - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) - - it('with its child, however the child ended, leaving no entry or timer', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - attempts.childEnded(SESSION, { ...CHILD, generation: 'generation-0' }) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.childEnded(SESSION, CHILD) - - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - expect(settled.map((startup) => startup.outcome)).toEqual(['ended']) - }) - - it('once an expiry has stopped its published child', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - vi.advanceTimersByTime(SILENCE_MS) - - expect(expired).toHaveLength(1) - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - }) - - it('when an acquire the expiry aborted is abandoned, or publishes late and is expired then', () => { - const abandoned = mint() - attempts.track(SESSION, abandoned).spawned() - vi.advanceTimersByTime(SILENCE_MS) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.abandon(SESSION, abandoned.attemptId) - expect(attempts.isOpen(SESSION)).toBe(false) - - const late = mint() - attempts.track(SESSION, late).spawned() - vi.advanceTimersByTime(SILENCE_MS) - attempts.published(SESSION, late.attemptId, { ...CHILD, phase: 'starting' }) - - expect(expired.map((startup) => startup.child)).toEqual([null, null, CHILD]) - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - // Each start is measured once, at the limit that ended it. - expect(settled.map((startup) => startup.outcome)).toEqual(['silent', 'silent']) - }) - - it('with an acquire that answered ready, or an attach that failed', () => { - const ready = mint() - attempts.track(SESSION, ready).spawned() - attempts.published(SESSION, ready.attemptId, { ...CHILD, phase: 'ready' }) - const failed = mint() - attempts.track('session-beta', failed).spawned() - attempts.abandon('session-beta', failed.attemptId) - - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(vi.getTimerCount()).toBe(0) - }) - - it('when the next attempt replaces it, and every clock stops at quit', () => { - const first = mint() - const firstProgress = attempts.track(SESSION, first) - firstProgress.spawned() - const second = mint() - attempts.track(SESSION, second).spawned() - // The replaced attempt's reports reach nothing. - firstProgress.output() - attempts.abandon(SESSION, first.attemptId) - expect(attempts.isOpen(SESSION)).toBe(true) - - attempts.dispose() - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts index 83b9c7a2a17..b5154de4580 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts @@ -1,14 +1,4 @@ -// One start of a conversation's provider child, minted by the host before any adapter acquires. -// -// Whatever a start means as policy belongs to the host and is the same for every agent: which -// conversation and lease it is for, what cancels it, when it gives up, and when its child may take -// input. An adapter only runs its protocol inside the attempt it is handed. -// -// The clock starts when the process exists, never before (resolving a launch is not starting it). -// A start that says nothing for a minute is wedged; one still talking is given up only at the -// ceiling, so a slow migration or history replay is not killed mid-way while every start still -// ends. Expiry re-derives what to end from the host's own state, so a timer that outlives its -// attempt ends nothing. +// The host owns start policy; expiry rechecks its current child before ending a stale attempt. import { randomUUID } from 'node:crypto' import { @@ -35,10 +25,14 @@ export function mintStructuredAgentSessionStartupAttempt(input: { permissionMode?: AgentChatPermissionMode events?: StructuredAgentSessionEventSink signal?: AbortSignal + startupOptions?: Readonly> + /** The id the host registered the start under, before any of its waits. */ + attemptId?: string }): StructuredAgentSessionStartupAttempt { const { record } = input + const options = input.startupOptions ?? record.options return { - attemptId: randomUUID(), + attemptId: input.attemptId ?? randomUUID(), identity: input.identity, fence: record.lease.runtimeFence, spawnToken: input.spawnToken, @@ -47,57 +41,46 @@ export function mintStructuredAgentSessionStartupAttempt(input: { accountHome: record.accountHome, ...(record.launchDirectory === undefined ? {} : { launchDirectory: record.launchDirectory }) }, - ...(record.options ? { options: record.options } : {}), + ...(options ? { options } : {}), ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), ...(input.events ? { events: input.events } : {}), ...(input.signal ? { signal: input.signal } : {}) } } -/** The startup limit's own reason, so an expired start is told from a close, Stop or quit. */ -export class StructuredAgentSessionStartupExpiredError extends Error { - constructor() { - super('the agent stopped making progress before it finished starting') - this.name = 'StructuredAgentSessionStartupExpiredError' - } -} - -export function isStructuredAgentSessionStartupExpired(reason: unknown): boolean { - return reason instanceof Error && reason.name === 'StructuredAgentSessionStartupExpiredError' -} - -/** Where an expired attempt stood: still inside its acquire, or published as a starting child. */ +/** Where an expired attempt stood: still spawning, or published as a starting child. */ export type StructuredAgentSessionExpiredStartup = { sessionId: string attemptId: string - /** Null while the acquire has not returned. */ + /** Null while spawn has not returned. */ child: StructuredAgentSessionProviderChildIdentity | null } /** How one spawned start ended, for tuning the limits from what agents really take. */ export type StructuredAgentSessionStartupSettled = { agent: AgentSessionJournalIdentity['agent'] - outcome: 'ready' | 'silent' | 'ceiling' | 'ended' + outcome: 'ready' | 'ceiling' | 'ended' /** From spawn to the outcome. */ durationMs: number } -/** What the host learns about a start while its acquire runs. */ +/** What the host learns about a start while spawn runs. */ export type StructuredAgentSessionStartupProgress = { - /** The process exists: the clock starts. */ + /** The process exists: its start is measured from here. */ spawned: () => void - /** Any output; output before a spawn report also starts the clock. */ - output: () => void } type Tracked = { - attempt: StructuredAgentSessionStartupAttempt + attemptId: string + /** Null until the start reaches its acquire. */ + attempt: StructuredAgentSessionStartupAttempt | null child: StructuredAgentSessionProviderChildIdentity | null + /** The ceiling counts from here: the start's registration, before any of its waits. */ + registeredAt: number spawnedAt: number | null - lastOutputAt: number timer: ReturnType | null expired: boolean - /** The limit's measure, held until an acquire it aborted settles: one that is ready anyway + /** The limit's measure, held until an aborted spawn settles: one that is ready anyway * reports that instead. */ expiry: StructuredAgentSessionStartupSettled | null readiness: PromiseWithResolvers<'ready' | 'ended'> @@ -113,7 +96,7 @@ export class StructuredAgentSessionStartupAttempts { constructor( private readonly deps: { - /** Runs outside the session's queue: an acquire may hold it for the whole handshake. */ + /** Runs outside the session's queue: spawn or open may hold it through the handshake. */ expire: (expired: StructuredAgentSessionExpiredStartup) => void /** Never throws back into the clock. */ settled?: (settled: StructuredAgentSessionStartupSettled) => void @@ -126,39 +109,47 @@ export class StructuredAgentSessionStartupAttempts { this.now = deps.now ?? Date.now } - track( - sessionId: string, - attempt: StructuredAgentSessionStartupAttempt - ): StructuredAgentSessionStartupProgress { + /** A start registered before its first wait: its ceiling runs from now. */ + begin(sessionId: string, attemptId: string): void { + this.register(sessionId, attemptId) + } + + private register(sessionId: string, attemptId: string): Tracked { this.end(sessionId) const tracked: Tracked = { - attempt, + attemptId, + attempt: null, child: null, + registeredAt: this.now(), spawnedAt: null, - lastOutputAt: 0, timer: null, expired: false, expiry: null, readiness: Promise.withResolvers() } this.open.set(sessionId, tracked) + if (!this.disposed) { + this.schedule(sessionId, tracked) + } + return tracked + } + + track( + sessionId: string, + attempt: StructuredAgentSessionStartupAttempt + ): StructuredAgentSessionStartupProgress { + const begun = this.open.get(sessionId) + const tracked = + begun?.attemptId === attempt.attemptId && !begun.attempt + ? begun + : this.register(sessionId, attempt.attemptId) + tracked.attempt = attempt const current = (): boolean => this.open.get(sessionId) === tracked && !this.disposed return { spawned: () => { if (current()) { - this.startClock(sessionId, tracked) + this.markSpawned(tracked) } - }, - output: () => { - if (!current()) { - return - } - if (tracked.spawnedAt === null) { - this.startClock(sessionId, tracked) - return - } - // Read when the timer fires, so a chatty child costs no timer churn. - tracked.lastOutputAt = this.now() } } } @@ -185,7 +176,7 @@ export class StructuredAgentSessionStartupAttempts { child: StructuredAgentSessionProviderChildIdentity & { phase?: 'starting' | 'ready' } ): void { const tracked = this.open.get(sessionId) - if (tracked?.attempt.attemptId !== attemptId) { + if (tracked?.attemptId !== attemptId) { return } if (child.phase === 'ready') { @@ -198,9 +189,7 @@ export class StructuredAgentSessionStartupAttempts { return } // A child published without a spawn report has existed since at least now. - if (tracked.spawnedAt === null && !this.disposed) { - this.startClock(sessionId, tracked) - } + this.markSpawned(tracked) } /** Whether `child`'s start is still on its clock: false once its limit passed, so a `started` @@ -231,10 +220,11 @@ export class StructuredAgentSessionStartupAttempts { } } - /** The attempt failed, or its attach did: nothing it started is left to time out. */ + /** The attempt failed, or its attach did: nothing it started is left to time out. A published + * child stays on its clock until it proves its start or ends. */ abandon(sessionId: string, attemptId: string): void { const tracked = this.open.get(sessionId) - if (tracked?.attempt.attemptId === attemptId) { + if (tracked?.attemptId === attemptId && !tracked.child) { this.settle(sessionId, tracked, 'ended') } } @@ -256,23 +246,12 @@ export class StructuredAgentSessionStartupAttempts { this.open.clear() } - private startClock(sessionId: string, tracked: Tracked): void { - if (tracked.spawnedAt !== null) { - return - } - tracked.spawnedAt = this.now() - tracked.lastOutputAt = tracked.spawnedAt - this.schedule(sessionId, tracked) + private markSpawned(tracked: Tracked): void { + tracked.spawnedAt ??= this.now() } private schedule(sessionId: string, tracked: Tracked): void { - if (tracked.spawnedAt === null) { - return - } - const due = Math.min( - tracked.lastOutputAt + this.limits.silenceMs, - tracked.spawnedAt + this.limits.ceilingMs - ) + const due = tracked.registeredAt + this.limits.ceilingMs tracked.timer = setTimeout(() => this.check(sessionId, tracked), Math.max(0, due - this.now())) // A start's clock must never be the reason a process stays alive at quit. tracked.timer.unref?.() @@ -280,21 +259,18 @@ export class StructuredAgentSessionStartupAttempts { private check(sessionId: string, tracked: Tracked): void { tracked.timer = null - if (this.open.get(sessionId) !== tracked || this.disposed || tracked.spawnedAt === null) { + if (this.open.get(sessionId) !== tracked || this.disposed) { return } - const now = this.now() - if (now - tracked.spawnedAt >= this.limits.ceilingMs) { + if (this.now() - tracked.registeredAt >= this.limits.ceilingMs) { this.expire(sessionId, tracked, 'ceiling') - } else if (now - tracked.lastOutputAt >= this.limits.silenceMs) { - this.expire(sessionId, tracked, 'silent') } else { this.schedule(sessionId, tracked) } } - /** `reason` null: a start whose limit already passed inside its acquire was published now. */ - private expire(sessionId: string, tracked: Tracked, reason: 'silent' | 'ceiling' | null): void { + /** `reason` null: a start whose limit already passed inside spawn was published now. */ + private expire(sessionId: string, tracked: Tracked, reason: 'ceiling' | null): void { if (this.open.get(sessionId) !== tracked || this.disposed) { return } @@ -302,14 +278,13 @@ export class StructuredAgentSessionStartupAttempts { tracked.expiry = this.measure(tracked, reason) } tracked.expired = true - const { child } = tracked // A published child's stop is under way and ends nothing a newer attempt owns; one still - // acquiring stays tracked until its aborted acquire is abandoned or published. - if (child) { + // spawning stays tracked until its aborted spawn is abandoned or published. + if (tracked.child) { this.report(tracked.expiry) this.end(sessionId) } - this.deps.expire({ sessionId, attemptId: tracked.attempt.attemptId, child }) + this.deps.expire({ sessionId, attemptId: tracked.attemptId, child: tracked.child }) } private settle(sessionId: string, tracked: Tracked, outcome: 'ready' | 'ended'): void { @@ -324,7 +299,7 @@ export class StructuredAgentSessionStartupAttempts { tracked: Tracked, outcome: StructuredAgentSessionStartupSettled['outcome'] ): StructuredAgentSessionStartupSettled | null { - return tracked.spawnedAt === null + return tracked.spawnedAt === null || !tracked.attempt ? null : { agent: tracked.attempt.identity.agent, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index cf49ea0b793..93a8412a365 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -79,13 +79,15 @@ const ended = { describe('a provider that ends before it finished starting', () => { it('tells the user why, even with no response in progress', async () => { - const session = startedSession() + const session = { + ...startedSession(), + child: { generation: GENERATION, fence: 7, phase: 'starting' as const } + } await settleStructuredAgentSessionChildExit(contextFor(session), { ...ended, // The adapter typed the start's own failure; the host keeps it rather than reword it. - failure: { kind: 'notSignedIn' }, - startupUnproven: true + failure: { kind: 'notSignedIn' } }) expect(session.appendLifecycleBatch).toHaveBeenCalledWith( @@ -115,7 +117,7 @@ describe('a provider that ends before it finished starting', () => { expect(session.appendLifecycleBatch).not.toHaveBeenCalled() }) - it("reads a start that failed off the host's own phase when the provider omits the flag", async () => { + it("reads a start that failed off the host's own phase", async () => { const session = { ...startedSession(), child: { generation: GENERATION, fence: 7, phase: 'starting' as const } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index e6c917ba563..0f722ad9959 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -1,3 +1,4 @@ +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' // A Stop's event, through the real host: written in the Stop's serialized step as it is accepted, // before the interrupt and before anything that ends the child, naming the turn, who asked and // the target it captured; never by a Stop that stopped nothing. @@ -54,10 +55,16 @@ async function queuedDraft(text: string): Promise { } /** The provider's turn row for the working send, which may land after a Stop. */ -async function turnRow(turnId: string, state: 'running' | 'interrupted') { +async function turnRow(turnId: string, state: 'running' | 'interrupted', userId?: string) { return journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, - { kind: 'turn', turnId, state, startedAt: 1 }, + { + kind: 'turn', + turnId, + state, + startedAt: 1, + ...(userId ? { userItemId: agentJournalSubmissionKey(userId) } : {}) + }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -197,7 +204,7 @@ describe("a Stop's event", () => { const working = await rig.workingSend() await rig.stop() const between = await queuedDraft('queued between the presses') - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) await rig.stop() expect(stopEvents()).toHaveLength(1) await rig.settleAccepted(working, 'stopped') @@ -207,10 +214,10 @@ describe("a Stop's event", () => { it('a second press after a card sent into the turn settled unknown writes again', async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const working = await rig.workingSend() await rig.stop() // The turn the working send started opens after that turnless Stop; the card goes into it. - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) @@ -229,7 +236,7 @@ describe("a Stop's event", () => { const working = await rig.workingSend() await rig.stop() // The turn the working send started opens after that turnless Stop; the card goes into it. - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) @@ -244,8 +251,8 @@ describe("a Stop's event", () => { it('naming no turn, names the turn running when it takes effect', async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() - await turnRow('turn-1', 'running') + const working = await rig.workingSend() + await turnRow('turn-1', 'running', working) await rig.stop() expect(stopEvents()).toMatchObject([{ reason: 'user-stop', turnId: 'turn-1' }]) }) @@ -267,7 +274,7 @@ describe("a Stop's event", () => { it('names a turn that ended while the next card is sent but shows no turn yet: writes nothing, and the card runs', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) const next = await queuedDraft('sent when turn-1 ends') await rig.settleAccepted(working, 'working') await turnRow('turn-1', 'interrupted') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index d514035812f..f28abc333f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -52,6 +52,9 @@ async function stopWhileTheTurnOpens( agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts index 5f3a934e91e..60582c11ddf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -81,6 +81,9 @@ async function stopEndingTheChild(options: { agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts index c3279ab1046..be9b163b2bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts @@ -134,6 +134,8 @@ it('is saved before it interrupts, and a retry of its id never stops the newer t expect((await rig.submission(behind))?.dispatchState).not.toBe('rejected') expect((await rig.submission(newer))?.dispatchState).not.toBe('rejected') expect(await rig.drafts()).toEqual([]) + expect(await rig.stop()).toMatchObject({ ok: true, replayed: false, value: { cancelled: true } }) + expect(rig.cancelTurn).toHaveBeenCalledTimes(2) }) // Round 1: a late Stop acted on newer work while the next message was handed over. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts deleted file mode 100644 index 0d3144dd76f..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ /dev/null @@ -1,936 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// The lifetime of a provider child, against the real host rather than a double. -// -// Two leaks meet here: a chat that closes without stopping its app-server, and a launch that -// starts one for every record on disk. A view never starts or keeps a child; work starts one, the -// idle sweep stops it, and an exit is settled and left for the next send. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionMutationEnvelope, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - AgentSessionAcquisitionRootExitObservedError, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const REQUESTED_CLOSE_END = { type: 'ended', reason: 'closed', cause: 'requested-close' } as const -const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'Codex stopped while this response was in progress. You can continue in this conversation.' - -const CALLER = { callerKey: 'client-1' } -/** Short enough to keep the suite fast; the host clock below decides what is idle. */ -const SWEEP_MS = 5 -const IDLE_MS = 1_000 - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let closeSession: Mock> -let dispatch: Mock -let sink: StructuredAgentSessionEventSink | null -let hostErrors: unknown[] -let statusSink: StructuredAgentSessionStatusSink -let clock: number -function adapter(): StructuredAgentSessionAdapter { - return startsWhenPublished( - { - acquire, - closeSession, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ) -} - -function openHost( - probeOwner?: (record: AgentSessionRecord) => Promise -): void { - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: { - warn: (_message, fields) => hostErrors.push(fields.error), - error: (_message, fields) => hostErrors.push(fields.error) - }, - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, - now: () => clock, - statusSink, - ...(probeOwner ? { probeOwner } : {}) - }) -} - -/** A fresh app generation over the same durable store, with its owner proven gone. */ -async function reboot(): Promise { - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - openHost(async () => ({ outcome: 'pid-absent' })) - acquire.mockClear() - closeSession.mockClear() -} - -async function attach(): Promise { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -} - -/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ -async function startAgent(): Promise { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) -} - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, - { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -/** The sweep stops the child first and closes the conversation last. */ -function waitForEviction(): Promise { - return vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - }) -} - -/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ -function waitOutSeveralSweeps(): Promise { - return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) -} - -/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. The - * stop drains once before it, to judge whether it ends work. */ -function failEvictionDrain(): void { - const sink = host['runtimeState'].eventSinkFor(SESSION) - const drained = sink.drained.bind(sink) - vi.spyOn(sink, 'drained') - .mockImplementationOnce(drained) - .mockResolvedValueOnce({ ok: false, error: new Error('drain barrier lost') }) -} - -/** The submissions as they stood when the session was forgotten; its journal is gone after that. */ -async function failJournalSinkUntilReleased(): Promise { - const session = ( - host as unknown as { - sessions: Map Promise } }> - } - ).sessions.get(SESSION) - expect(session).toBeDefined() - vi.spyOn(session!.journal, 'appendItem').mockRejectedValueOnce(new Error('disk unavailable')) - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { kind: 'exit-observed' } - }) - }) -} - -/** Replaces the failed cached sink so suite cleanup can drain the host. */ -function replaceFailedSink(): void { - ;( - host as unknown as { - runtimeState: { eventSinkFor: (sessionId: string) => unknown } - } - ).runtimeState.eventSinkFor(SESSION) -} - -function captureSettledSubmissions(): { value: AgentJournalSubmission[] } { - const captured: { value: AgentJournalSubmission[] } = { value: [] } - const journal = host['sessions'].get(SESSION)!.journal - const closeJournal = journal.close.bind(journal) - vi.spyOn(journal, 'close').mockImplementation(async () => { - captured.value = journal.snapshot().submissions - await closeJournal() - }) - return captured -} - -async function sendPending(text: string): Promise { - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage(text) - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-surface-lifetime-')) - resetHostTestOperationIds() - sink = null - hostErrors = [] - clock = NOW - statusSink = { publish: vi.fn(), forget: vi.fn() } - let generation = 0 - acquire = vi.fn(async ({ fence, spawnToken, events }) => { - sink = events ?? null - return { - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${++generation}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - } - }) - // Like real adapters, the close reports its own end onto the chat's lane before it returns. - closeSession = vi.fn(async (sessionId: string) => { - const child = host['sessions'].get(sessionId)?.child - if (child?.generation) { - void host.handleAdapterEvent({ - ...REQUESTED_CLOSE_END, - sessionId, - fence: child.fence, - acquisitionGeneration: child.generation - }) - } - return true - }) - dispatch = vi.fn(async () => ({ - state: 'rejected' as const, - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - })) - store = await openTestAgentSessionRecordStore(root) - openHost() -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('a chat that closes', () => { - it('returns the receipt for a stopped child whose provider reports its own end', async () => { - await attach() - await host['tasks'].drainAttaches() - const reported = vi.spyOn(host, 'handleAdapterEvent') - - expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) - expect(reported).toHaveBeenCalledWith(expect.objectContaining({ cause: 'requested-close' })) - // History stays saved while the conversation handle closes. - expect([store.getRecord(SESSION) !== null, host.hasSession(SESSION)]).toEqual([true, false]) - expect(await host.sleepWorkspace('other-workspace')).toEqual({ stoppedSessionIds: [] }) - }) - - it('omits an unproven workspace child from the stop receipt', async () => { - await attach() - closeSession.mockResolvedValueOnce(false) - - expect(await host.sleepWorkspace('workspace-1')).toEqual({ - stoppedSessionIds: [], - failures: [{ sessionId: SESSION, reason: 'stop-unverifiable' }] - }) - expect(store.getRecord(SESSION)).not.toBeNull() - }) - - it('does not count a chat whose send was accepted after the stop as stopped', async () => { - await attach() - await vi.waitFor(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - const close = host['lifetime'].closeForWorkspaceSleep - vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { - const result = await close(...args) - const body = hostTestMessage('newer send') - // The later work exists once the send is accepted, not merely once it is queued. - const sent = host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - expect(await sent).toMatchObject({ ok: true }) - return result - }) - - expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) - }) - - it('stops the provider child it started', async () => { - await attach() - - await host.close(SESSION, 'evict') - - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - expect(hostErrors).toEqual([]) - // The record and its journal stay; only the process and the claim on it go. - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { kind: 'exit-observed' } - }) - }) - - // The pane outlives the close by a few frames — a workspace delete closes the chats inside it - // while their panes are still mounted. A read in that window reopens the conversation as a cache - // and is answered, never refused; it starts nothing. - it('answers a read from the pane that outlived it without starting a child', async () => { - await attach() - - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - const unsubscribe = await host.subscribe({ - id: 'sub-1', - sessionId: SESSION, - emit: () => undefined - }) - unsubscribe() - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('answers a compatibility wait with what eviction recorded', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending until close') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(result).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - if (!result.ok) { - throw new Error('send was refused') - } - // Handed over first: a message still queued at close is rejected as never sent instead. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()) - const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - - await host.close(SESSION, 'evict') - - // Eviction's settlement is a journal write, so the wait sees it rather than timing out. - await expect(settlement).resolves.toMatchObject({ - value: { - submission: { dispatchState: 'unknown', reason: 'provider_closed_before_acknowledgement' } - } - }) - }) - - it('keeps the stop it made when the journal close loses its result', async () => { - await attach() - const session = host['sessions'].get(SESSION) - expect(session).toBeDefined() - const closeJournal = session!.journal.close.bind(session!.journal) - vi.spyOn(session!.journal, 'close') - .mockImplementationOnce(async () => { - await closeJournal() - throw new Error('journal close result lost') - }) - .mockImplementation(closeJournal) - - // The child is stopped and the lease released before the handle closes; the entry is dropped - // before that close, so a lost result leaves no closing handle for a reader to find. - await expect(host.close(SESSION, 'evict')).rejects.toThrow('journal close result lost') - expect(host.hasSession(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeSession).toHaveBeenCalledOnce() - }) - - it('reports a failed drain after the child stopped, and still settles and releases', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending across an aborted eviction') - const sent = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - const session = host['sessions'].get(SESSION) - expect(session).toBeDefined() - failEvictionDrain() - const settled = captureSettledSubmissions() - - // The child is proven gone: the failed drain is bookkeeping, reported, and the rest still runs. - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(session!.child).toBeNull() - expect(closeSession).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - expect(hasUnansweredStructuredAgentSessionDispatch(settled.value)).toBe(false) - }) -}) - -describe('a session with a turn in flight', () => { - it('keeps a running turn and sleeps only proven ended chats after idle eviction or restart', async () => { - await attach() - emitTurnLifecycle('running', 1) - await host.flushStreamedEvents(SESSION) - - clock += IDLE_MS - await waitOutSeveralSweeps() - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - - emitTurnLifecycle('completed', 2) - await host.flushStreamedEvents(SESSION) - clock += IDLE_MS - - await waitForEviction() - expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) - await abandonStructuredAgentSessionHost(host) - await reboot() - await host.restoreReadableSessions() - expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) - const getRecord = store.getRecord.bind(store) - const withoutDeath = (record: ReturnType) => - record && { ...record, lease: { ...record.lease, deathEvidence: null } } - vi.spyOn(store, 'getRecord').mockImplementation((id) => withoutDeath(getRecord(id))) - const receipt = await host.sleepWorkspace('workspace-1') - expect(receipt).toMatchObject({ failures: [{ reason: 'stop-unverifiable' }] }) - }) - - // Codex settles an admitted send only on its echo, which may never come; the stop retires it. - it('is stopped with an admitted send outstanding once no turn runs', async () => { - await attach() - await sendPending('admitted, never echoed') - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - - clock += IDLE_MS - - await waitForEviction() - }) -}) - -describe('startup', () => { - it('settles an idle absent owner without chat pollution and resumes the same provider identity', async () => { - await attach() - const beforeRestart = store.getRecord(SESSION) - await abandonStructuredAgentSessionHost(host) - - store = await openTestAgentSessionRecordStore(root) - openHost(async () => ({ outcome: 'pid-absent' })) - await host.restoreReadableSessions() - - const restored = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(restored.ok && restored.page.items.some((item) => item.body.kind === 'status')).toBe( - false - ) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - - await startAgent() - expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( - beforeRestart?.providerHandleChain.at(-1)?.handle - ) - expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.origin).toBe('resumed') - }) - - it('restores a session for reading without spawning a provider child', async () => { - await attach() - await reboot() - - await host.restoreReadableSessions() - - // The record is readable — the tab comes back, history answers — and nothing is running. - expect(acquire).not.toHaveBeenCalled() - expect(host.listSessionTabs()).toEqual([ - { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } - ]) - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('gives the child back for work, never for a read', async () => { - await attach() - await reboot() - await host.restoreReadableSessions() - - const unsubscribe = await host.subscribe({ - id: 'viewer-1', - sessionId: SESSION, - emit: () => undefined - }) - expect(acquire).not.toHaveBeenCalled() - await startAgent() - unsubscribe() - - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeKind: 'native', - ownerProcess: { pid: 4242 } - }) - }) -}) - -describe('a session closed and started again', () => { - it('publishes provider events to the reattached chat', async () => { - await attach() - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - - await startAgent() - const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = await host.subscribe({ - id: 'subscriber-1', - sessionId: SESSION, - emit: (event) => events.push(event) - }) - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - sink?.publish() - await host.flushStreamedEvents(SESSION) - unsubscribe() - - expect(JSON.stringify(events)).toContain('back again') - }) -}) - -async function submissionState(clientMessageId: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - )?.dispatchState -} - -describe('an unexpected provider exit', () => { - it('publishes terminal settlement to a waiting older client', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending until provider exit') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(result).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - if (!result.ok) { - throw new Error('send was refused') - } - // Accepted first; the exit must meet a message the provider was handed. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - await expect(settlement).resolves.toMatchObject({ - value: { submission: { dispatchState: 'unknown' } } - }) - }) - - it('turns a journal sink failure into observed-exit settlement and lease release', async () => { - await attach() - - await failJournalSinkUntilReleased() - - expect(dispatch).not.toHaveBeenCalled() - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect( - history.ok && - history.page.items.some( - (item) => item.body.kind === 'status' && item.body.text.includes('journal sink failure') - ) - ).toBe(false) - replaceFailedSink() - }) - - // TypeScript accepts the body; the persisted reader rejects a call id that is only spaces. - it('stops a provider whose output would not read back, and the next send starts a child', async () => { - await attach() - - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) - }) - expect(hostErrors).toContainEqual(expect.objectContaining({ code: 'journal_row_rejected' })) - replaceFailedSink() - - // Nothing restarts it on its own; the next send does, and the chat still loads. - expect(acquire).toHaveBeenCalledOnce() - await startAgent() - expect(acquire).toHaveBeenCalledTimes(2) - emitTurnLifecycle('running', 2) - await host.flushAllStreamedEvents() - await reboot() - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - }) - - it('settles a journal sink failure whose stop saw the provider root exit', async () => { - await attach() - // The lease follows the root, so its seen exit settles like a proven one. - closeSession.mockRejectedValueOnce( - new AgentSessionAcquisitionRootExitObservedError(new Error('provider close unproven')) - ) - - await failJournalSinkUntilReleased() - - replaceFailedSink() - }) - - it('releases the exact generation, starts nothing, and the next message starts a child', async () => { - await attach() - dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) - const unknownBody = hostTestMessage('message with unknown delivery') - const unknownEnvelope = envelope('agentSession.send', { body: unknownBody }) - await expect( - host.send(CALLER, { envelope: unknownEnvelope, body: unknownBody }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // Accepted, then handed over by the delivery loop, where the thrown dispatch becomes doubt. - await vi.waitFor(async () => - expect(await submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') - ) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - const recoveredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect( - recoveredHistory.ok && - hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions) - ).toBe(false) - // No respawn: the exit is settled and shown, and the conversation waits for work. - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1 - }) - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('a distinct next message') - const nextEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') - ) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('does not reacquire for a live reader or a stale child generation', async () => { - await attach() - const unsubscribe = await host.subscribe({ - id: 'subscriber-1', - sessionId: SESSION, - emit: () => undefined - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'stale child exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-stale' - }) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'current child exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1, - deathEvidence: { kind: 'exit-observed' } - }) - unsubscribe() - }) - - it('ends the record of a requested close as a close, never as a crash to recover from', async () => { - await attach() - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider closed', - cause: 'requested-close', - fence, - acquisitionGeneration: 'generation-1' - }) - - expect(acquire).toHaveBeenCalledOnce() - expect(host['sessions'].get(SESSION)?.child).toBeNull() - expect(host['sessions'].get(SESSION)?.lastEndedChild).not.toHaveProperty('failure') - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('recovers after a failed lifecycle barrier and dispatches a distinct next message', async () => { - await attach() - dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) - const unknownBody = hostTestMessage('message with unknown delivery') - const unknownParams = { - envelope: envelope('agentSession.send', { body: unknownBody }), - body: unknownBody - } - await expect(host.send(CALLER, unknownParams)).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') - ) - const runtimeState = ( - host as unknown as { - runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } - } - ).runtimeState - vi.spyOn(runtimeState, 'lifecycleBarrier').mockResolvedValueOnce({ - ok: false, - error: new Error('journal failed') - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1 - }) - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(hostErrors).toContainEqual(expect.objectContaining({ message: 'journal failed' })) - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') - // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — its failure fact beside it, and never the old bare `Provider exited: ` row. - const statuses = history.ok - ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - : [] - expect(statuses).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) - expect(statuses.some((text) => text.startsWith('Provider exited'))).toBe(false) - - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('a distinct next message after failed-barrier recovery') - const nextEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') - ) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('releases the lease when the exit settlement cannot be written, and the next send settles the turn it left', async () => { - await attach() - emitTurnLifecycle('running', 1) - await host.flushStreamedEvents(SESSION) - const runtimeState = ( - host as unknown as { - runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } - } - ).runtimeState - vi.spyOn(runtimeState, 'lifecycleBarrier').mockResolvedValueOnce({ - ok: false, - error: new Error('journal failed') - }) - const session = ( - host as unknown as { - sessions: Map< - string, - { journal: { appendLifecycleBatch: (...args: never[]) => Promise } } - > - } - ).sessions.get(SESSION) - expect(session).toBeDefined() - // The conversation's one handle refuses every write of the exit's settlement. - const refusing = vi - .spyOn(session!.journal, 'appendLifecycleBatch') - .mockRejectedValue(new Error('settlement still unavailable')) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - const released = store.getRecord(SESSION)?.lease - expect(released).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - runtimeFence: exitedFence + 1, - deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW } - }) - // The retry recording the exit queues is refused too; once the journal writes again, the next - // acquire re-derives it. - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - refusing.mockRestore() - - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('sent after a settlement that never landed') - const sentEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: sentEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(sentEnvelope.clientOperationId)).toBe('accepted') - ) - expect(acquire).toHaveBeenCalledTimes(2) - // The new child's acquire settled the turn from the release's evidence: ended at the exit's - // receipt. The evidence is Orca's log text, so the row says only that the provider stopped. - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - const items = history.ok ? history.page.items : [] - expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( - expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', completedAt: NOW }) - ) - const statuses = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) - expect(statuses).toContainEqual({ - kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: { kind: 'providerExited' }, - tone: 'error' - }) - expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') - }) -}) - -describe('a quit after a close whose drain failed', () => { - // The close's own wind-down hands the lease back past a failed drain, so quit finds nothing owed. - it('has nothing of that child left to finish', async () => { - await attach() - await sendPending('pending across a failed drain') - const settled = captureSettledSubmissions() - failEvictionDrain() - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() - - await host.flushAllStreamedEvents() - - expect(closeSession).toHaveBeenCalledOnce() - expect(host.hasSession(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - expect(hasUnansweredStructuredAgentSessionDispatch(settled.value)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 3101059e85c..8a886e5ab88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -19,6 +19,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import type { StructuredAgentRegistry } from './structured-agent-registry' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { throwIfSignalAborted } from '../../../shared/abort-signal-reason' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' function refused( @@ -66,7 +67,7 @@ export function journalRecordsThreadGoalChange( export async function performThreadGoalChange( ctx: AgentSessionTurnContext, - input: { clientOperationId: string; change: AgentSessionThreadGoalChange } + input: { clientOperationId: string; change: AgentSessionThreadGoalChange; signal?: AbortSignal } ): Promise> { if ( !ctx.adapter.changeThreadGoal || @@ -84,6 +85,7 @@ export async function performThreadGoalChange( } // Journal first: an active goal starts provider work at once, and the objective // must land ahead of that work in the transcript. + throwIfSignalAborted(input.signal) if (change.kind === 'set') { await ctx.journal.appendItem( identity, @@ -105,11 +107,13 @@ export async function performThreadGoalChange( } let result: Awaited> try { + throwIfSignalAborted(input.signal) result = await ctx.adapter.changeThreadGoal({ sessionId: ctx.sessionId, fence: ctx.fence, change, - replacesGoal + replacesGoal, + signal: input.signal }) } catch (error) { await withdrawObjective() @@ -125,6 +129,7 @@ export async function performThreadGoalChange( export function threadGoalPlan(params: { envelope: AgentSessionMutationEnvelope change: AgentSessionThreadGoalChange + signal?: AbortSignal }): MutationPlan { const value: AgentSessionThreadGoalResult = { change: params.change.kind } return { @@ -133,7 +138,8 @@ export function threadGoalPlan(params: { run: (ctx) => performThreadGoalChange(ctx, { clientOperationId: params.envelope.clientOperationId, - change: params.change + change: params.change, + signal: params.signal }), // A lost response is answered from the journal, which the provider keeps // current; otherwise the change runs again, which is safe for every kind. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts deleted file mode 100644 index dcfb9c1f666..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { refuse, type AgentSessionOptionResult } from '../../../shared/agent-session-wire' -import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' -import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' - -export async function performSetOption( - ctx: Pick< - AgentSessionTurnContext, - 'sessionId' | 'adapter' | 'fence' | 'persistOptions' | 'publish' - >, - input: { key: string; value: string }, - signal?: AbortSignal -): Promise> { - let applied: void | Readonly> - try { - applied = await ctx.adapter.setOption({ - sessionId: ctx.sessionId, - ...input, - fence: ctx.fence, - ...(signal ? { signal } : {}) - }) - } catch (error) { - if (isAgentSessionOptionRejectedError(error)) { - return { - ok: false, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: error.refusalReason }, - error.message - ) - } - } - throw error - } - await ctx.persistOptions(applied ?? { [input.key]: input.value }) - ctx.publish() - return { ok: true, value: { ...input, ...(applied ? { options: { ...applied } } : {}) } } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 138fe64a07b..277602ce0cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -642,6 +642,7 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( agent: 'codex', adapter: { acquire: vi.fn(), + open: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), cancelTurn: vi.fn(async () => outcome), @@ -727,6 +728,7 @@ describe('the note a Stop writes', () => { agent: 'codex', adapter: { acquire: vi.fn(), + open: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), cancelTurn: vi.fn(async () => ({ cancelled: true })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index ac9dd8baf42..e6bf58e0730 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -6,11 +6,8 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' +import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -23,24 +20,10 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' -import { - contextStructuredAgentSessionCurrentWork, - type StructuredAgentSessionCurrentWork -} from './structured-agent-session-current-work' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { whileStructuredAgentSessionHandingOver } from './structured-agent-session-handing-over' import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { - handOverStructuredAgentSessionCommand, - structuredAgentSessionHandoverOrigin, - type StructuredAgentSessionCommandHandoverContext -} from './structured-agent-session-command-turn' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { classifyJournalOpenFailure, isJournalWrittenByNewerOrca, @@ -50,9 +33,6 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' import { CommandReceiptExistsError } from '../agent-session-journal/command-receipt-transaction' import type { StructuredAgentRegistry } from './structured-agent-registry' -import { carriedStoppedHistory } from './structured-agent-session-stopped-history-selection' -import { withStoppedHistory } from '../../../shared/agent-session-stopped-history' -export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -113,29 +93,6 @@ export function agentSessionAttachmentExpiredRefusal(): { ) } -/** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown`, never - * replayed. Only a child that proved its start is handed anything, so a start that fails leaves its - * messages unsent instead (the delivery loop's barrier); an adapter that knows a write never - * happened answers `rejected`. */ -async function dispatchSafely( - ctx: AgentSessionHandoverContext, - clientMessageId: string, - body: AgentJournalMessageItem, - requestedAt: number -): Promise { - try { - return await ctx.adapter.dispatch({ - sessionId: ctx.sessionId, - clientMessageId, - body, - fence: ctx.fence, - requestedAt - }) - } catch (error) { - return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } - } -} - /** * One id, one delivery. A submission that already exists replays its recorded * outcome and NEVER goes back on the wire, whatever state it is in and whatever @@ -156,7 +113,7 @@ export async function performSend( origin?: 'client' | 'host' /** Who it is from; the submission keeps the kind only. */ source?: AgentSessionMessageSource - /** The model and effort it runs on, applied at its handover. */ + /** The send's `options` record, read once here into the choices the message keeps. */ options?: Readonly> } ): Promise> { @@ -175,9 +132,13 @@ export async function performSend( value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } + const captured = captureStructuredExecutionOptions(ctx, input.options) + if ('refusal' in captured) { + return { ok: false, refusal: captured.refusal } + } try { await ctx.journal.appendSubmission( - { ...input, fence: ctx.fence, handoverRecorded: true }, + { ...input, options: captured.options, fence: ctx.fence, handoverRecorded: true }, undefined, ctx.operationReceipt ) @@ -214,108 +175,6 @@ export async function performSend( } } -export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { - /** What is current changed with no row (`publishGenerationEnded`). */ - workChanged?: () => void -} - -/** - * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash - * after it leaves a message in doubt, never one that reads as queued and so provably unwritten. - */ -export async function handOverSubmission( - ctx: AgentSessionHandoverContext, - submission: AgentJournalSubmission -): Promise { - const { clientMessageId } = submission - const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) - if (body?.kind !== 'message') { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), - fence: ctx.fence - }) - return - } - await whileStructuredAgentSessionHandingOver( - ctx.journal, - clientMessageId, - async (released) => { - if (body.command) { - await handOverStructuredAgentSessionCommand(ctx, submission, body) - return - } - // Join only the live child's turn; a new turn carries the stopped history on its first row. - const turnScope = contextStructuredAgentSessionCurrentWork(ctx).turnScope() - const carried = carriedStoppedHistory(ctx, turnScope, submission, body) - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'pending', - fence: ctx.fence, - turnScope, - ...(carried ? { carries: carried.covers } : {}) - }) - const outcome = await dispatchSafely( - ctx, - clientMessageId, - carried ? withStoppedHistory(body, carried.messages) : body, - structuredAgentSessionHandoverOrigin(ctx.journal, submission) - ) - // An admission needs no dispatch row: the submission is already pending, and from here the - // child's own report says whether it still holds it. Any other outcome's row says so. - if (outcome.state === 'admitted') { - released() - return - } - await resolveHandoverOutcome(ctx, clientMessageId, outcome) - }, - ctx.workChanged - ) -} - -async function resolveHandoverOutcome( - ctx: AgentSessionHandoverContext, - clientMessageId: string, - outcome: Exclude -): Promise { - try { - await ctx.journal.resolveDispatch( - outcome.state === 'accepted' - ? { - clientMessageId, - state: 'accepted', - providerIdentity: outcome.providerIdentity, - fence: ctx.fence - } - : outcome.state === 'rejected' - ? { - clientMessageId, - state: 'rejected', - reason: outcome.reason, - rejection: outcome.rejection, - ...(outcome.answeredInTurn ? { answeredInTurn: outcome.answeredInTurn } : {}), - fence: ctx.fence - } - : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } - ) - } catch (error) { - // A failed resolution must not strand a pending row; an unknown result is - // explicitly replayable. - try { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'unknown', - reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, - fence: ctx.fence - }) - } catch { - // Nothing further to record; the pending row is settled on the next open. - } - throw error - } -} - function requireSubmission( ctx: AgentSessionTurnContext, clientMessageId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 0891fa6f170..62cbe626007 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -439,4 +439,34 @@ describe('provider-exit settlement', () => { expect(generationEnded).toHaveBeenLastCalledWith(SESSION, { restate: true }) expect(log.scopes()).toEqual(['exit-lifecycle-barrier', 'exit-settlement']) }) + + it('ignores an exit reported for an earlier generation at the same fence', async () => { + const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })) + const session: StructuredAgentSessionChildExitSession = { + child: { generation: GENERATION, fence: 7, phase: 'ready' }, + journal: exitJournalDouble({ items: () => [], appendLifecycleBatch }) + } + const { store } = mutableStore() + const context: StructuredAgentSessionChildExitContext = { + logger: recordingStructuredAgentSessionLogger().logger, + store, + sessions: new Map([[SESSION, session]]), + flushLifecycle: async () => ({ ok: true }), + generationEnded: vi.fn(), + serialize: async (_sessionId, task) => task(), + now: () => 1 + } + await settleStructuredAgentSessionChildExit(context, { + type: 'ended', + sessionId: SESSION, + reason: 'provider exited', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: 'generation-stale' + }) + + expect(store.getRecord().lease.claimStatus).toBe('live') + expect(session.child?.generation).toBe(GENERATION) + expect(appendLifecycleBatch).not.toHaveBeenCalled() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 32a0b6c2c2b..dc53536a6b1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -133,14 +133,17 @@ function openHost(overrides: Partial = {}): void logger: createStructuredAgentSessionLogger(), store, adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, - releaseAcquisition: vi.fn(async () => undefined), + releaseAcquisition: vi.fn(async () => false), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), setOption: vi.fn(), supportsCreate: () => true - } as unknown as StructuredAgentSessionAdapter, + }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', @@ -152,13 +155,17 @@ function openHost(overrides: Partial = {}): void /** The host starting the agent with no message to deliver, as an operation that needs it does. */ function startAgent(): Promise { - return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) + const context = host['mutationContext']() + return context + .serialize(SESSION, async () => context.claimAgent(SESSION)) + .then((claim) => claim.outcome) } beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wedged-profile-')) resetHostTestOperationIds() acquire = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -696,6 +703,7 @@ describe('already-wedged profiles become usable on load', () => { acquire.mockImplementation(async ({ fence }) => { order.push('acquire') return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-new' }, link: { linkId: `link-${fence}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index dded754b3db..2aa3d7a7252 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -54,6 +54,7 @@ import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journ import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' /** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the * projection's own unanswered-dispatch rule, which is what makes that window read as `working`. */ @@ -176,11 +177,7 @@ function liveTasks( type WorkingCandidateSession = { journal: AgentSessionJournal /** Only this host generation's own child counts. A restored-for-reading journal has none. */ - child: { - fence: number - phase?: 'starting' | 'ready' - heldSends?: () => ReadonlySet | undefined - } | null + child: Pick | null } /** The offer one session is owed, taken right before teardown stops its provider child; null when @@ -193,8 +190,6 @@ export function structuredAgentSessionWorkingAtStop(input: { childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined /** Child work the provider reported running and not over, recorded or not. */ childWorkOpen: (sessionId: string) => boolean - /** False when the session's live child never answered its start; the exit reads the same fact. */ - startAnswered?: (sessionId: string) => boolean | undefined trigger: AgentSessionResumeTrigger /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string @@ -208,8 +203,7 @@ export function structuredAgentSessionWorkingAtStop(input: { const current = agentSessionCurrentContextRows(snapshot.items, snapshot.submissions) // A queued message reached no agent, and one handed to a start that never answered ran nowhere: // neither is work to resume. The next open or the exit keeps it as unsent. - const unanswered = - session.child.phase === 'starting' || input.startAnswered?.(sessionId) === false + const unanswered = session.child.phase === 'starting' const handedOver = current.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 5b2d8115522..0592ed283da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -8,7 +8,15 @@ import { claudeAndCodexAgents } from './structured-agent-session-adapter-router- import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { expect, it, vi } from 'vitest' -import { hostTestMessage } from './structured-agent-session-host-test-data' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { + createRestTestRig, + foundRestTestChat, + IDLE_MS +} from './structured-agent-session-rest-test-rig' import { attach, CALLER, @@ -17,6 +25,7 @@ import { replaceHostTestState, attachParams } from './structured-agent-session-host-test-harness' +import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' it('omits an unverified stop superseded by a newer send while retaining a sibling receipt', async () => { const { host, store, acquire } = hostTestState() @@ -163,3 +172,138 @@ it('keeps a send admitted after the host Sleep close alive', async () => { expect(close).toHaveBeenCalledOnce() expect(host.hasSession('session-alpha')).toBe(true) }) + +it('returns the receipt for a stopped child whose provider reports its own end', async () => { + const { host, store } = hostTestState() + await attach() + await host['tasks'].drainAttaches() + // Like real adapters, the close reports its own end onto the chat's lane before it returns. + host.deps.adapter.closeSession = vi.fn(async (sessionId: string) => { + const child = host['sessions'].get(sessionId)?.child + if (child?.generation) { + void host.handleAdapterEvent({ + type: 'ended', + reason: 'closed', + cause: 'requested-close', + sessionId, + fence: child.fence, + acquisitionGeneration: child.generation + }) + } + return true + }) + const reported = vi.spyOn(host, 'handleAdapterEvent') + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + expect(reported).toHaveBeenCalledWith(expect.objectContaining({ cause: 'requested-close' })) + // History stays saved while the conversation handle closes. + expect([store.getRecord(SESSION) !== null, host.hasSession(SESSION)]).toEqual([true, false]) + expect(await host.sleepWorkspace('other-workspace')).toEqual({ stoppedSessionIds: [] }) +}) + +it('omits an unproven workspace child from the stop receipt', async () => { + const { host, store } = hostTestState() + await attach() + host.deps.adapter.closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: [], + failures: [{ sessionId: SESSION, reason: 'stop-unverifiable' }] + }) + expect(store.getRecord(SESSION)).not.toBeNull() +}) + +it('does not count a chat whose send was accepted after the stop as stopped', async () => { + const { host } = hostTestState() + await attach() + host.deps.adapter.closeSession = vi.fn(async () => true) + await vi.waitFor(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) + const close = host['lifetime'].closeForWorkspaceSleep + vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { + const result = await close(...args) + const body = hostTestMessage('newer send') + // The later work exists once the send is accepted, not merely once it is queued. + const sent = host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + expect(await sent).toMatchObject({ ok: true }) + return result + }) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) +}) + +// A start runs outside the chat's lane, so Sleep reaches it through the start's own abort. +it('stops an agent that is still starting', async () => { + const { host, acquire } = hostTestState() + await attach() + const closeSession = vi.fn(async () => true) + host.deps.adapter.closeSession = closeSession + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + let opening = false + host.deps.adapter.open = vi.fn(async ({ signal }) => { + opening = true + return await new Promise((_, reject) => + signal.addEventListener('abort', () => reject(signal.reason)) + ) + }) + const body = hostTestMessage('send while the agent starts') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(opening).toBe(true)) + closeSession.mockClear() + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host['runtimeState'].startFlights.has(SESSION)).toBe(false) + expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() +}) + +// Before the adapter owns a process, the aborted start leaves the chat at rest: that is stopped. +it('stops an agent whose process is still being acquired', async () => { + const { host, acquire } = hostTestState() + await attach() + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + let acquiring = false + acquire.mockImplementationOnce(async ({ signal }) => { + acquiring = true + return await new Promise((_, reject) => + signal?.addEventListener('abort', () => reject(signal.reason)) + ) + }) + const body = hostTestMessage('send while the agent is acquired') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(acquiring).toBe(true)) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + expect(host['runtimeState'].startFlights.has(SESSION)).toBe(false) + expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() +}) + +it('sleeps only proven ended chats after idle eviction or restart', async () => { + const rig = await createRestTestRig() + try { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => { + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + expect(rig.host.hasSession(SESSION)).toBe(false) + }) + expect(await rig.host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + await rig.restart() + await rig.host.restoreReadableSessions() + expect(await rig.host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + const getRecord = rig.store.getRecord.bind(rig.store) + vi.spyOn(rig.store, 'getRecord').mockImplementation((id) => { + const record = getRecord(id) + return record && { ...record, lease: { ...record.lease, deathEvidence: null } } + }) + expect(await rig.host.sleepWorkspace('workspace-1')).toMatchObject({ + failures: [{ reason: 'stop-unverifiable' }] + }) + } finally { + await rig.dispose() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts index cb7b9a43f54..b5c65dee63e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts @@ -11,6 +11,7 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { sameProviderChild } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' /** Stops one chat captured by a workspace Sleep and reports whether the stop is proven. */ @@ -69,15 +70,71 @@ export async function stopWorkspaceSleepChat(input: { !sessions.get(sessionId)?.child && ended?.rootGone === true && sameProviderChild(ended, child) - // A released reservation may still have an unproven root after restart; only - // exit evidence or a never-started record proves an unheld chat is stopped. const alreadyStopped = - !!after && - after.lease.runtimeFence === input.fence && - agentSessionLeaseIsReleased(after.lease) && - (agentSessionLeaseOwnerVerdict(after.lease) === 'exited' || - structuredSessionNeverStarted(after)) && - !sessions.get(sessionId)?.child && - deps.adapter.hasOwnedAcquisition?.(sessionId) !== true + after?.lease.runtimeFence === input.fence && workspaceSleepChatAtRest(deps, sessions, sessionId) return childStopped || ownedStopped || alreadyStopped } + +/** A released reservation may still have an unproven root after restart; only exit evidence or a + * never-started record proves an unheld chat is stopped. */ +function workspaceSleepChatAtRest( + deps: Pick, + sessions: StructuredAgentSessionConversations, + sessionId: string +): boolean { + const record = deps.store.getRecord(sessionId) + return ( + !!record && + agentSessionLeaseIsReleased(record.lease) && + (agentSessionLeaseOwnerVerdict(record.lease) === 'exited' || + structuredSessionNeverStarted(record)) && + !sessions.get(sessionId)?.child && + deps.adapter.hasOwnedAcquisition?.(sessionId) !== true + ) +} + +/** The same abort a close gives a registered start, remembering which starts it aborted so the + * result is read once they settle, outside the lane their last commits need. */ +export function workspaceSleepStartAborts( + runtimeState: () => Pick< + StructuredAgentSessionHostRuntimeState, + 'startFlights' | 'acquireAborts' + >, + sessionId: string +) { + const aborted: Promise[] = [] + return { + abort: (): void => { + const flight = runtimeState().startFlights.get(sessionId) + if (flight) { + aborted.push(flight.outcome) + } + runtimeState().acquireAborts.abort(sessionId, 'closed while starting') + }, + settle: ( + input: Pick[0], 'deps' | 'sessions' | 'sessionId'>, + result: { fence: number; stopped: boolean } | null + ) => + settleWorkspaceSleepAbortedStarts(input, result, aborted, () => + runtimeState().startFlights.has(sessionId) + ) + } +} + +/** A start the stop aborted settles after the stop's step; once it has, and no newer start runs, + * the chat it left at rest counts as stopped. */ +async function settleWorkspaceSleepAbortedStarts( + input: Pick[0], 'deps' | 'sessions' | 'sessionId'>, + result: { fence: number; stopped: boolean } | null, + abortedStarts: readonly Promise[], + startRunning: () => boolean +): Promise<{ fence: number; stopped: boolean } | null> { + await Promise.allSettled(abortedStarts) + if (!result || result.stopped || abortedStarts.length === 0 || startRunning()) { + return result + } + const { deps, sessions, sessionId } = input + return workspaceSleepChatAtRest(deps, sessions, sessionId) + ? { fence: deps.store.getRecord(sessionId)?.lease.runtimeFence ?? result.fence, stopped: true } + : result +} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 7191ada60a8..12451d92a4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -103,21 +103,13 @@ beforeEach(async () => { adapter = { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async (input) => { acquisitions++ const acquisitionGeneration = `acquisition-${acquisitions}` - const starter = host - // The handshake answers at once, reporting what the child runs with. - void starter.handleAdapterEvent({ - type: 'started', - sessionId: input.identity.sessionId, - fence: input.fence, - acquisitionGeneration, - reportedOptions: { model: '' } - // Never admitted, so the empty report persists nothing. - }) return { - providerChildPhase: 'starting' as const, acquisitionGeneration, process: { hostId: 'local', diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 807bd612748..1f8c2799b5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -150,7 +150,8 @@ async function commandTurn(clientMessageId: string) { return (await journal()).items.find((item) => item.itemId === itemId) } -it('answers at handover, then journals its own entry, turn and result (B1, B16)', async () => { +it('answers at handover before the provider reports holding the command, then journals its result', async () => { + Object.assign(state.host.deps.adapter, { heldSends: () => new Set() }) await attach() const events = await subscribe() const params = compactParams() @@ -379,7 +380,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B void state.host['tasks'].serialize(SESSION, () => held.promise) const commanded = state.host.conversationCommand(CALLER, params) void state.host['tasks'].serialize(SESSION, async () => { - events?.appendItem( + await events?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, { kind: 'turn', turnId: 'provider-turn', state: 'running' }, { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index 240b2ea262c..d87318ef302 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -1,8 +1,10 @@ import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' -import { structuredRewindCut } from './structured-rewind-cut' +import { isDeepStrictEqual } from 'node:util' +import { structuredRewindCut, type StructuredRewindCut } from './structured-rewind-cut' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -61,6 +63,38 @@ async function settleUnsupportedClaudeRewind( } } +type RecoveredRewind = Awaited< + ReturnType> +> + +/** What the provider says about a rewind in doubt, read with no journal or record write, so a + * caller can read it outside the session's serialize and apply it inside. Null: none in doubt. */ +export type StructuredRewindRecoveryEvidence = { + rewind: AgentSessionRewindRecord + turns?: Pick + recovered?: RecoveredRewind +} | null + +// Only this rewind moves a chat to a new epoch once it has history (imports run at creation), so +// a moved epoch is its own replacement, committed before the crash. +function rewindJournalCut( + record: AgentSessionRecord | null, + journal: AgentSessionJournal, + rewind: AgentSessionRewindRecord +): { alreadyReplaced: boolean; cut: StructuredRewindCut | null } { + const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch + const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle + const cut = + alreadyReplaced || !record || !head + ? null + : structuredRewindCut(journal, { + itemId: rewind.itemId, + provider: record.provider, + nativeId: head.nativeId + }) + return { alreadyReplaced, cut } +} + /** Recovery observes provider state; it never repeats an ambiguous native mutation. */ export async function recoverStructuredRewind( deps: RewindRecoveryDeps, @@ -75,30 +109,79 @@ export async function recoverStructuredRewind( ) => JournalOperationReceipt, signal?: AbortSignal ): Promise { + const evidence = await readStructuredRewindRecovery( + deps, + sessionId, + journal, + fence, + adapter, + signal + ) signal?.throwIfAborted() + await applyStructuredRewindRecovery(deps, sessionId, journal, fence, evidence, receipt) +} + +/** `record` is the one the apply will see, when a caller's commit before it changes the record. */ +export async function readStructuredRewindRecovery( + { store }: RewindRecoveryDeps, + sessionId: string, + journal: AgentSessionJournal, + fence: number, + adapter?: StructuredAgentSessionAdapter, + signal?: AbortSignal, + record = store.getRecord(sessionId) +): Promise { + signal?.throwIfAborted() + const rewind = record?.rewind + if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { + return null + } + const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId) + const { cut } = rewindJournalCut(record, journal, rewind) + if (target?.provider === 'claude' || rewind.phase !== 'prepared' || !cut) { + return { rewind } + } + const turns = { beforeTurnId: cut.beforeTurnId, keptTurnId: cut.keptTurnId } + const read = adapter?.recoverRewind?.({ + sessionId, + fence, + ...turns, + ...(signal ? { signal } : {}) + }) + const recovered = signal + ? await waitForPromiseWithSignal(Promise.resolve(read), signal) + : await read + return recovered ? { rewind, turns, recovered } : { rewind, turns } +} + +/** The journal and record writes the evidence proves; a rewind or cut that moved since the read + * is not this evidence's to settle. */ +export async function applyStructuredRewindRecovery( + deps: RewindRecoveryDeps, + sessionId: string, + journal: AgentSessionJournal, + fence: number, + evidence: StructuredRewindRecoveryEvidence, + receipt?: ( + rewind: AgentSessionRewindRecord, + fence: number, + cursor: AgentJournalCursor + ) => JournalOperationReceipt +): Promise { const { store } = deps let rewind = store.getRecord(sessionId)?.rewind - if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { + if (!evidence || (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared')) { return } + if (!isDeepStrictEqual(rewind, evidence.rewind)) { + throw new Error('agent_session_rewind:stale-evidence') + } const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId) if (target?.provider === 'claude') { await settleUnsupportedClaudeRewind(deps, sessionId, fence, rewind) return } - // Only this rewind moves a chat to a new epoch once it has history (imports run at creation), so - // a moved epoch is its own replacement, committed before the crash. - const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch - const record = store.getRecord(sessionId) - const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle - const cut = - alreadyReplaced || !record || !head - ? null - : structuredRewindCut(journal, { - itemId: rewind.itemId, - provider: record.provider, - nativeId: head.nativeId - }) + const { alreadyReplaced, cut } = rewindJournalCut(store.getRecord(sessionId), journal, rewind) if (!alreadyReplaced && !cut) { throw new Error('agent_session_rewind:invalid-target') } @@ -106,17 +189,13 @@ export async function recoverStructuredRewind( if (!cut) { throw new Error('agent_session_rewind:outcome-unknown') } - const read = adapter?.recoverRewind?.({ - sessionId, - fence, - beforeTurnId: cut.beforeTurnId, - keptTurnId: cut.keptTurnId, - ...(signal ? { signal } : {}) - }) - const recovered = signal - ? await waitForPromiseWithSignal(Promise.resolve(read), signal) - : await read - signal?.throwIfAborted() + if ( + evidence.turns?.beforeTurnId !== cut.beforeTurnId || + evidence.turns.keptTurnId !== cut.keptTurnId + ) { + throw new Error('agent_session_rewind:stale-evidence') + } + const { recovered } = evidence if (!recovered?.ok) { // The target is still in the provider's history, and the journal is replaced only once the // revert is proven, so both still hold it even when the provider acknowledged the revert. diff --git a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts index 1a7f5e75416..cf9d3afdf86 100644 --- a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts +++ b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { CLAUDE_PLUGIN_DIR_FLAG, type ClaudeCliFlag } from '../claude/claude-cli-flag-support' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor @@ -99,8 +100,9 @@ describe('inline visuals launch preference', () => { prepareVisuals }) const adapter = adapterFor(codex, {}, [], { resolveLaunch }) + // The thread opens after the acquire returns; each check below reads its `thread/start`. const start = (sessionId: string) => - adapter.acquire({ + acquireReadyCodexForTest(adapter, { identity: { ...identityFor(sessionId), hostId: LOCAL_EXECUTION_HOST_ID, diff --git a/src/main/native-chat/structured-chat-naming-command-first.test.ts b/src/main/native-chat/structured-chat-naming-command-first.test.ts deleted file mode 100644 index 730b66be541..00000000000 --- a/src/main/native-chat/structured-chat-naming-command-first.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { createStructuredChatNamingHandler } from './structured-chat-naming' -import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' -import type { StructuredAgentSessionAdapter } from './agent-session-wire/structured-agent-session-adapter' -import { - hostTestState, - envelope -} from './agent-session-wire/structured-agent-session-host-test-harness' -import { - hostTestAttachParams, - hostTestMessage, - HOST_TEST_SESSION, - HOST_TEST_THREAD -} from './agent-session-wire/structured-agent-session-host-test-data' - -describe.each(['claude', 'codex'] as const)('%s command-first naming', (provider) => { - it.each(['Please repair login', ''])( - 'keeps the first real input eligible after compact: %j', - async (input) => { - const { host, store, acquire, dispatch, log } = hostTestState() - const compact = vi.fn>(async () => ({ - state: 'accepted', - providerIdentity: null - })) - host.deps.adapter.compact = compact - const handle = - provider === 'claude' - ? claudeProviderHandle(HOST_TEST_THREAD, null) - : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle, - origin: 'created', - mintedAtFence: fence, - observedAt: 100 - } - })) - dispatch.mockResolvedValue({ state: 'admitted' }) - const readFirstPrompt = vi.fn(async (sessionId: string, startedAt: number) => - firstStructuredChatNamingPrompt(await host.journalSnapshot(sessionId), startedAt) - ) - const generate = vi.fn(async () => 'Saved first input') - const naming = createStructuredChatNamingHandler({ - getStore: () => store, - getSettings: () => ({}), - workerTaskTitle: () => null, - readFirstPrompt, - generate, - onNamed: vi.fn(), - logger: log.logger - }) - const observer = vi.fn(naming) - host.deps.onSessionStatusChanged = observer - expect( - await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider, - agent: provider, - providerHandle: - provider === 'claude' - ? { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null } - : { kind: 'codex', threadId: HOST_TEST_THREAD }, - accountHome: { - variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: '/isolated/account' - } - }) - ) - ).toMatchObject({ ok: true }) - expect( - await host.conversationCommand( - { callerKey: 'client-1' }, - { - command: 'compact', - envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) - } - ) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) - expect( - observer.mock.calls.some( - ([summary, options]) => - summary.status === 'working' && options.firstInputSubmissionKey === null - ) - ).toBe(true) - const events = acquire.mock.calls.at(-1)?.[0].events - const command = compact.mock.calls[0]?.[0].command - if (!events || !command) { - throw new Error('Missing actual compact turn') - } - for (let index = 0; index < 12; index++) { - events.publish() - } - await host.flushStreamedEvents(HOST_TEST_SESSION) - expect(readFirstPrompt).not.toHaveBeenCalled() - events.appendLifecycleBatch?.( - 'compact-finished', - [ - { - kind: 'item', - identity: command.identity, - body: { - ...command.running, - state: 'completed', - outcome: 'success', - completedAt: Date.now() - }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ], - { lifecycle: true } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - const body = hostTestMessage(input) - expect( - await host.send( - { callerKey: 'client-1' }, - { body, envelope: envelope('agentSession.send', { body }) } - ) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(readFirstPrompt).toHaveBeenCalledTimes(1)) - if (input) { - await vi.waitFor(() => - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('Saved first input') - ) - expect(generate).toHaveBeenCalledExactlyOnceWith(expect.anything(), input) - } else { - expect(generate).not.toHaveBeenCalled() - } - for (let index = 0; index < 12; index++) { - events.publish() - } - const later = hostTestMessage('Later input must not rename') - await host.send( - { callerKey: 'client-1' }, - { body: later, envelope: envelope('agentSession.send', { body: later }) } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - expect(readFirstPrompt).toHaveBeenCalledTimes(1) - expect(generate).toHaveBeenCalledTimes(input ? 1 : 0) - } - ) -}) diff --git a/src/main/native-chat/structured-chat-naming-status-hook.test.ts b/src/main/native-chat/structured-chat-naming-status-hook.test.ts deleted file mode 100644 index f00d207591d..00000000000 --- a/src/main/native-chat/structured-chat-naming-status-hook.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import type { StructuredChatNamingDeps } from './structured-chat-naming' -import { createStructuredChatNamingHandler } from './structured-chat-naming' -import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' -import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { - hostTestState, - envelope -} from './agent-session-wire/structured-agent-session-host-test-harness' -import { - hostTestAttachParams, - hostTestMessage, - HOST_TEST_SESSION, - HOST_TEST_THREAD -} from './agent-session-wire/structured-agent-session-host-test-data' - -describe.each(['claude', 'codex'] as const)('%s first-message naming status hook', (provider) => { - it.each([ - { outcome: 'accepted', completesDuringRead: false }, - { outcome: 'admitted', completesDuringRead: false }, - { outcome: 'accepted', completesDuringRead: true }, - { outcome: 'admitted', completesDuringRead: true } - ] as const)( - 'names a live $outcome send (first turn completes during journal read: $completesDuringRead)', - async ({ outcome, completesDuringRead }) => { - const { host, store, acquire, dispatch, log } = hostTestState() - const handle = - provider === 'claude' - ? claudeProviderHandle(HOST_TEST_THREAD, null) - : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(HOST_TEST_SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle, - origin: 'created', - mintedAtFence: fence, - observedAt: 100 - } - })) - dispatch.mockImplementation(async () => - outcome === 'admitted' - ? { state: 'admitted' } - : { - state: 'accepted', - providerIdentity: - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-1' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 } - } - ) - let finish: (value: string | null) => void = () => { - throw new Error('Not initialized') - } - const generation = new Promise((resolve) => { - finish = resolve - }) - const journalRead = Promise.withResolvers() - const readFirstPrompt = vi.fn(async (sessionId: string, hostStartedAt: number) => { - await journalRead.promise - return firstStructuredChatNamingPrompt(await host.journalSnapshot(sessionId), hostStartedAt) - }) - const generate = vi.fn(() => generation) - const deps: StructuredChatNamingDeps = { - getStore: () => store, - getSettings: () => ({}), - workerTaskTitle: () => null, - readFirstPrompt, - generate, - onNamed: vi.fn(), - logger: log.logger - } - const naming = createStructuredChatNamingHandler(deps) - const status = vi.fn(naming) - host.deps.onSessionStatusChanged = status - const attached = await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider, - agent: provider, - providerHandle: - provider === 'claude' - ? { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null } - : { kind: 'codex', threadId: HOST_TEST_THREAD }, - accountHome: { - variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: '/isolated/account' - } - }) - ) - expect(attached).toMatchObject({ ok: true }) - const body = hostTestMessage('Please repair the login flow') - const sent = await host.send( - { callerKey: 'client-1' }, - { - envelope: envelope('agentSession.send', { body }), - body - } - ) - expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(readFirstPrompt).toHaveBeenCalledTimes(1)) - expect(generate).not.toHaveBeenCalled() - if (completesDuringRead) { - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - const events = acquire.mock.calls.at(-1)?.[0].events - const first = (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( - (item) => item.body.kind === 'message' && item.body.role === 'user' - ) - if (!events || !first) { - throw new Error('Missing acquired first turn') - } - events.appendItem( - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-1' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, - body, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - events.appendItem( - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-completion' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 2 }, - { kind: 'turn', turnId: 'turn-1', userItemId: first.itemId, state: 'completed' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - events.publish() - await host.flushStreamedEvents(HOST_TEST_SESSION) - await vi.waitFor(() => - expect(status.mock.calls.at(-1)?.[0]).toMatchObject({ status: 'idle' }) - ) - expect(generate).not.toHaveBeenCalled() - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBeUndefined() - } - journalRead.resolve() - await vi.waitFor(() => expect(generate).toHaveBeenCalledTimes(1)) - expect( - status.mock.calls.some( - ([summary, options]) => - summary.agent === provider && summary.status === 'working' && !options.replay - ) - ).toBe(true) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBeUndefined() - finish('auth/login') - await vi.waitFor(() => - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('auth/login') - ) - } - ) -}) diff --git a/src/main/native-chat/structured-chat-naming.test.ts b/src/main/native-chat/structured-chat-naming.test.ts index abb907ec48e..892705354ab 100644 --- a/src/main/native-chat/structured-chat-naming.test.ts +++ b/src/main/native-chat/structured-chat-naming.test.ts @@ -8,6 +8,17 @@ import { createStructuredChatNamingHandler, type StructuredChatNamingDeps } from './structured-chat-naming' +import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' +import { + attach, + CALLER, + envelope, + hostTestState +} from './agent-session-wire/structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION, + hostTestMessage +} from './agent-session-wire/structured-agent-session-host-test-data' function deferred() { let resolve: (value: T) => void = () => { @@ -300,3 +311,27 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid print.mockRestore() }) }) + +it("names a chat from a real host's status feed once its first send runs", async () => { + const { host, store, log } = hostTestState() + host.deps.onSessionStatusChanged = createStructuredChatNamingHandler({ + getStore: () => store, + getSettings: () => ({}), + workerTaskTitle: () => null, + readFirstPrompt: async (id, startedAt) => + firstStructuredChatNamingPrompt(await host.journalSnapshot(id), startedAt), + generate: async () => 'auth/login', + onNamed: vi.fn(), + logger: log.logger + }) + await attach() + + const body = hostTestMessage('Please repair the login flow') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + + await vi.waitFor(() => + expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('auth/login') + ) +}) diff --git a/src/main/pi/pi-scripted-child.test-fixture.ts b/src/main/pi/pi-scripted-child.test-fixture.ts new file mode 100644 index 00000000000..f1852ac9514 --- /dev/null +++ b/src/main/pi/pi-scripted-child.test-fixture.ts @@ -0,0 +1,267 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import type { JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' +import { JsonlRpcResponseError, type JsonlRpcRecord } from '../jsonl-rpc/peer' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { ScriptedAgentChild } from '../runtime/structured-agent-scripted-child.test-fixture' +import type { PiRpcConnection } from './rpc-session' + +class ScriptedPiConnection implements PiRpcConnection { + closed = false + rootVerdict: PiRpcConnection['rootVerdict'] = 'live' + processless = false + lastCloseResult: PiRpcConnection['lastCloseResult'] = null + readonly pid: number + readonly file: string + private readonly handshake = Promise.withResolvers() + private readonly exitListeners = new Set<() => void>() + private firstStateRead = true + private streaming = false + private model = { provider: 'scripted', id: 'model-1', reasoning: true } + private effort = 'medium' + + constructor( + launch: ProviderProcessLaunch, + private readonly handlers: JsonlRpcAgentConnectionOptions, + private readonly script: ScriptedAgentChild, + private readonly received: string[], + private readonly wire: string[], + private readonly onClosed: () => void, + number: number, + /** A Pi with no login runs the unknown provider and lists no model. */ + private readonly signedOut = false + ) { + this.pid = 42_000 + number + const sessionIndex = launch.args.indexOf('--session') + this.file = + sessionIndex !== -1 + ? launch.args[sessionIndex + 1] + : join(tmpdir(), 'orca-scripted-pi', `session-${number}.jsonl`) + void this.handshake.promise.catch(() => undefined) + } + + async request(command: string, params: Record = {}): Promise { + if (this.closed) { + throw new Error('Scripted Pi connection closed') + } + this.wire.push(command) + if (command === 'get_state') { + if (this.firstStateRead) { + this.firstStateRead = false + if (!this.script.holdHandshakes) { + this.releaseHandshake() + } + return this.handshake.promise + } + return this.state() + } + if (command === 'get_available_models') { + return { models: this.signedOut ? [] : [this.model] } + } + if (command === 'get_commands') { + return { commands: [] } + } + if ( + command === 'set_model' && + typeof params.provider === 'string' && + typeof params.modelId === 'string' + ) { + this.model = { ...this.model, provider: params.provider, id: params.modelId } + return this.model + } + if (command === 'set_thinking_level' && typeof params.level === 'string') { + this.effort = params.level + return {} + } + if (command === 'abort') { + this.endTurn('aborted') + return {} + } + throw new Error(`Unscripted Pi request: ${command}`) + } + + async send(frame: JsonlRpcRecord): Promise { + if (this.closed || frame.type !== 'prompt' || typeof frame.message !== 'string') { + throw new Error('Scripted Pi requires a live prompt') + } + this.received.push(frame.message) + this.wire.push(`prompt:${frame.message}`) + if (this.signedOut) { + this.emit({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for scripted' + }) + return + } + if (!this.streaming) { + this.streaming = true + this.emit({ type: 'agent_start' }) + } + this.emit({ type: 'message_end', message: { role: 'user', content: frame.message } }) + this.emit({ + type: 'response', + command: 'prompt', + success: true, + data: { disposition: 'started' } + }) + if (this.script.completeTurns) { + this.endTurn('stop') + } + } + + releaseHandshake(): void { + if (!this.closed) { + this.handshake.resolve(this.state()) + } + } + + failHandshake(message: string): void { + const error = withProviderDiagnostic( + new JsonlRpcResponseError('get_state', message), + providerDiagnostic(message, 'person') + ) + this.handshake.reject(error) + this.exit(error) + } + + async close(): Promise>> { + if (!this.closed) { + this.onClosed() + const error = new Error('Scripted Pi closed') + this.handshake.reject(error) + this.exit(error) + } + return { root: 'exited', tree: 'exited' } + } + + onExit(listener: () => void): void { + if (this.rootVerdict === 'exited') { + listener() + } else { + this.exitListeners.add(listener) + } + } + + pauseReading(): void {} + resumeReading(): void {} + + private exit(error: Error): void { + if (this.closed) { + return + } + this.closed = true + this.rootVerdict = 'exited' + this.lastCloseResult = { root: 'exited', tree: 'exited' } + this.exitListeners.forEach((listener) => listener()) + this.exitListeners.clear() + this.handlers.onExit?.(error, { + expected: false, + exit: { code: 0, signal: null, processless: false } + }) + } + + private endTurn(stopReason: string): void { + if (!this.streaming) { + return + } + this.streaming = false + this.emit({ + type: 'message_end', + message: { role: 'assistant', content: 'Scripted Pi reply', stopReason } + }) + this.emit({ type: 'agent_end' }) + this.emit({ type: 'agent_settled' }) + } + + private emit(frame: JsonlRpcRecord): void { + this.handlers.onRecord?.(frame) + } + + private state() { + return { + sessionFile: this.file, + // As a signed-out Pi reports it (capture `signed-out.jsonl`). + model: this.signedOut ? { provider: 'unknown', id: 'unknown', reasoning: false } : this.model, + thinkingLevel: this.effort, + isStreaming: this.streaming, + isCompacting: false, + pendingMessageCount: 0 + } + } +} + +export type ScriptedPiChild = ScriptedAgentChild & { + /** Every request command and prompt any spawn received, in order. */ + wire(): readonly string[] + signIn(): void +} + +export const piScriptedChild = (options: { signedOut?: boolean } = {}): ScriptedPiChild => { + const children: ScriptedPiConnection[] = [] + const received: string[] = [] + const wire: string[] = [] + let resumed = 0 + let closed = 0 + let signedOut = options.signedOut ?? false + const script: ScriptedPiChild = { + holdHandshakes: true, + completeTurns: true, + deps: { + resolvePiLaunch: async ({ identity }) => { + const handle = identity.providerHandle + if (handle && (handle.transport !== 'jsonl-rpc' || handle.agent !== 'pi')) { + throw new Error('Scripted Pi received another provider handle') + } + return { + command: join(tmpdir(), 'orca-scripted-pi', 'fake-pi'), + cwd: tmpdir(), + fullAccess: true, + previous: handle + ? { + linkId: 'scripted-previous', + handle, + origin: 'created', + mintedAtFence: 0, + observedAt: Date.now() + } + : null, + ...(handle ? { sessionFile: handle.nativeId } : {}) + } + }, + openPiConnection: (launch, handlers) => { + if (launch.args.includes('--session')) { + resumed += 1 + } + const child = new ScriptedPiConnection( + launch, + handlers, + script, + received, + wire, + () => { + closed += 1 + }, + children.length + 1, + signedOut + ) + children.push(child) + return child + }, + readProcessStartTime: async () => 1_700_000_000_000 + }, + releaseHandshake: () => children.at(-1)?.releaseHandshake(), + failHandshake: (message) => children.at(-1)?.failHandshake(message), + prompts: () => received, + spawns: () => children.length, + resumes: () => resumed, + closes: () => closed, + wire: () => wire, + signIn: () => { + signedOut = false + } + } + return script +} diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts deleted file mode 100644 index 467417c8ead..00000000000 --- a/src/main/pi/rpc-configured-default.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -// Pi's `--list-models` names no model it runs by default, so a new Pi chat with no saved pick showed -// "Model" and then whatever its session started on. Through the real Pi probe, adapter and catalog -// host, a chat started with no pick teaches the host the account's default for the next new chat. - -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionModelCatalogResult } from '../../shared/agent-session-wire' -import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' -import { - applyStructuredAgentSessionModelCatalog, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../shared/structured-agent-session-options' -import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' -import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { - agentReadsProjectModelConfig, - workspaceMayOverrideDefaultModel -} from '../native-chat/agent-model-catalog/agent-project-model-override' -import { - closeProviderTimelineRigs, - openProviderTimelineRig -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { agentModelCatalogFingerprint } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import { createPiModelCatalogProbe, piModelCatalogFromListing } from './rpc-model-catalog-probe' -import type { PiRpcConnection } from './rpc-session' -import { PiRpcSessionAdapter } from './rpc-session-adapter' - -const PI_HOME = { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' } -// What `pi --list-models` prints: the first row is not the model Pi starts on. -const LISTING = [ - 'provider model context max-out thinking images', - 'anthropic claude-sonnet-4 200K 64K yes yes', - 'openai gpt-6 400K 128K yes yes' -].join('\n') -const AVAILABLE = [ - { provider: 'anthropic', id: 'claude-sonnet-4', name: 'Claude Sonnet 4', reasoning: true }, - // Its thinking map adds xhigh, which `--list-models` never shows. - { - provider: 'openai', - id: 'gpt-6', - name: 'GPT-6', - reasoning: true, - thinkingLevelMap: { xhigh: 'xhigh' } - } -] -const RUNS = AVAILABLE[1]! - -let root: string -const adapters: PiRpcSessionAdapter[] = [] -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-pi-configured-default-')) -}) -afterEach(async () => { - for (const adapter of adapters.splice(0)) { - await adapter.closeAll() - } - rmSync(root, { recursive: true, force: true }) - await closeProviderTimelineRigs() -}) - -function workspace(name: string, files: Record = {}): string { - const dir = join(root, name) - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, '.git'), 'gitdir: /elsewhere') - for (const [file, text] of Object.entries(files)) { - mkdirSync(join(dir, file, '..'), { recursive: true }) - writeFileSync(join(dir, file), text) - } - return dir -} - -type PiChild = { connection: PiRpcConnection; switchTo: (modelId: string) => void } - -/** A Pi child that starts on `startsOn` at `thinking`; a signed-out one lists no model. `switchTo` - * is a switch Orca didn't send, as an extension's `setModel` makes. */ -function piChild(available: readonly object[], thinking: string, startsOn: string): PiChild { - let model: object | null = AVAILABLE.find((entry) => entry.id === startsOn) ?? RUNS - const switchTo = (modelId: string): void => { - model = AVAILABLE.find((entry) => entry.id === modelId) ?? model - } - const connection: PiRpcConnection = { - pid: 4123, - closed: false, - rootVerdict: 'live', - processless: false, - lastCloseResult: null, - request: async (command: string, params?: Record) => { - if (command === 'get_state') { - return { - sessionFile: '/homes/pi/sessions/one.jsonl', - isStreaming: false, - isCompacting: false, - model, - thinkingLevel: thinking - } - } - if (command === 'get_available_models') { - return { models: available } - } - if (command === 'set_model') { - switchTo(String(params?.modelId)) - } - return command === 'get_commands' ? { commands: [] } : {} - }, - send: async () => {}, - close: async () => ({ root: 'exited', tree: 'exited' }), - pauseReading: () => {}, - resumeReading: () => {}, - onExit: () => {} - } - return { connection, switchTo } -} - -type ChatOptions = { - saved?: Record - resumed?: boolean - forked?: boolean - /** A new Pi session standing in for one whose saved file was gone. */ - replacement?: boolean - available?: object[] - thinking?: string - startsOn?: string - /** What happens between the chat's start and the pane's options read. */ - afterStart?: (chat: { - child: PiChild - adapter: PiRpcSessionAdapter - sessionId: string - }) => Promise -} - -/** A host with Pi's real probe and catalog service, and one Pi chat in `workspacePath`. */ -async function piHost() { - const records = new Map() - const clock = { now: 1_000 } - const store = new AgentModelCatalogStore({ now: () => clock.now }) - const catalog = createAgentModelCatalogService({ - store, - getRecord: (sessionId) => records.get(sessionId), - drivesRecord: () => true, - resolveAccountHome: async () => PI_HOME, - recordWorkspacePath: async (row) => row.launchDirectory ?? null, - agentReadsProjectModelConfig, - workspaceMayOverrideDefaultModel, - probes: { - pi: createPiModelCatalogProbe({ - resolveEnvironment: async () => ({ PATH: '/usr/bin' }), - resolveCommand: () => '/opt/pi/bin/pi', - probeVersion: async () => true, - homePath: '/home/user', - runListing: async () => LISTING - }) - } - }) - - let chats = 0 - /** A Pi chat in `workspacePath`: the host saves what its start listed, then the pane reads its - * options once, and the host saves each read's listing too. */ - async function chat(workspacePath: string, opts: ChatOptions = {}) { - const sessionId = `pi-${++chats}` - let child: PiChild | undefined - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only these fields. - records.set(sessionId, { - sessionId, - provider: 'pi', - accountHome: PI_HOME, - launchDirectory: workspacePath, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'ws-1', - workspaceKind: 'git-worktree' - } - } as AgentSessionRecord) - const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) - const adapter = new PiRpcSessionAdapter({ - resolveLaunch: async () => ({ - command: '/opt/pi/bin/pi', - cwd: workspacePath, - fullAccess: true, - previous: null, - ...(opts.resumed ? { sessionFile: '/homes/pi/sessions/one.jsonl' } : {}), - ...(opts.forked ? { forkFile: '/homes/pi/sessions/elsewhere.jsonl' } : {}), - ...(opts.replacement ? { replacement: 'unsaved' as const } : {}) - }), - readProcessStartTime: async () => 1, - openConnection: () => { - child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high', opts.startsOn ?? '') - return child.connection - }, - onLifecycle: vi.fn(), - onSettled: vi.fn(), - onIdle: vi.fn(), - logger: { warn: vi.fn(), error: vi.fn() } - }) - adapters.push(adapter) - const acquired = await adapter.acquire({ - identity: { - sessionId, - workspaceId: 'ws-1', - hostId: 'local', - agent: 'pi', - providerHandle: null - }, - fence: 1, - spawnToken: 'spawn-token', - options: opts.saved ?? {}, - events: rig.eventSink - }) - if (!acquired.catalogListing) { - throw new Error('a Pi start hands the host its listing') - } - catalog.recordLiveListing(sessionId, acquired.catalogListing) - if (opts.afterStart && child) { - await opts.afterStart({ child, adapter, sessionId }) - } - const read = async (): Promise => { - const { catalogListing } = await adapter.readOptions({ sessionId, fence: 1 }) - if (catalogListing) { - catalog.recordLiveListing(sessionId, catalogListing) - } - } - await read() - return { listing: acquired.catalogListing, read } - } - return { catalog, chat, store, clock } -} - -/** What a new Pi chat's composer paints first from a host answer. */ -function firstFrame(answer: AgentSessionModelCatalogResult) { - const seed = structuredAgentSessionSeedCatalog('pi') - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('pi', seed), - seed, - answer, - { newLaunch: true } - ) - const snapshot = structuredAgentSessionOptionSnapshot(state) - const select = (id: string) => { - const entry = snapshot.find((descriptor) => descriptor.id === id) - return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null - } - return { model: select('model'), effort: select('effort') } -} - -const NOTHING = { model: null, effort: null } - -describe('Pi’s default comes from what a chat with no pick runs', () => { - it('names no model from the listing, then the model and thinking level a no-pick chat ran', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - const listed = await catalog.read({ agent: 'pi', workspacePath: clean, waitForListing: true }) - expect(listed).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) - expect(firstFrame(listed)).toEqual(NOTHING) - - await chat(clean) - // The next new chat, in this workspace or another whose config picks nothing. - for (const path of [clean, workspace('other')]) { - const answer = await catalog.read({ agent: 'pi', workspacePath: path }) - expect(answer).toMatchObject({ listingNamesConfiguredModel: true }) - expect(answer).not.toHaveProperty('defaultHoldsInEveryWorkspace') - expect(firstFrame(answer)).toEqual({ model: 'openai/gpt-6', effort: 'high' }) - } - }) - - it('keeps the placeholder in a workspace whose Pi settings pick the model', async () => { - const { catalog, chat } = await piHost() - await chat(workspace('clean')) - const configured = workspace('configured', { - '.pi/settings.json': '{"defaultProvider":"anthropic","defaultModel":"claude-sonnet-4"}' - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: configured }))).toEqual( - NOTHING - ) - }) - - it('learns nothing from a chat whose workspace picks its model', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(workspace('configured', { '.pi/extensions/pick.ts': 'export default () => {}\n' })) - expect( - firstFrame(await catalog.read({ agent: 'pi', workspacePath: workspace('clean') })) - ).toEqual(NOTHING) - }) - - it('learns nothing from a chat that restored a pick or resumed a conversation', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - const clean = workspace('clean') - expect((await chat(clean, { saved: { model: 'openai/gpt-6' } })).listing).not.toHaveProperty( - 'configuredDefault' - ) - expect((await chat(clean, { resumed: true })).listing).not.toHaveProperty('configuredDefault') - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) - }) - - it('saves no default from a signed-out Pi, which lists no model', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - expect((await chat(clean, { available: [] })).listing).not.toHaveProperty('configuredDefault') - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) - - // Nor does it forget one a signed-in chat taught. - await chat(clean) - await chat(clean, { available: [] }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('teaches the model the chat started on, not one an extension switched to later', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(clean, { afterStart: async ({ child }) => child.switchTo('claude-sonnet-4') }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('teaches the start model after a pick made in the chat, never the pick', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(clean, { - afterStart: async ({ adapter, sessionId }) => { - await adapter.setOption({ - sessionId, - fence: 1, - key: 'model', - value: 'anthropic/claude-sonnet-4' - }) - } - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('learns nothing from a chat forked from a conversation in another folder', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - const clean = workspace('clean') - expect((await chat(clean, { forked: true })).listing).not.toHaveProperty('configuredDefault') - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) - }) - - it('keeps a learned thinking level only the chat’s menu offers when a later listing is newer', async () => { - const { catalog, chat, store, clock } = await piHost() - const clean = workspace('clean') - await chat(clean, { thinking: 'xhigh' }) - // A background re-list after the chat: `--list-models` offers no xhigh. - clock.now = 2_000 - store.recordSuccess( - agentModelCatalogFingerprint({ agent: 'pi', accountHome: PI_HOME, wslDistro: null }), - 'pi', - { - models: piModelCatalogFromListing(LISTING), - origin: 'probe' - }, - 'discovery' - ) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'xhigh' - }) - }) - - it('a chat’s later reads teach nothing: a newer chat’s default stands', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - const first = await chat(clean) - // Pi's own default changed since (a `/model` in a terminal): the next new chat starts on it. - await chat(clean, { startsOn: 'claude-sonnet-4' }) - // The first chat keeps running and reads its options again, as every turn does. - await first.read() - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'anthropic/claude-sonnet-4', - effort: 'high' - }) - }) - - it('learns the model, but not the thinking level, from a chat that restored only its thinking level', async () => { - const { chat } = await piHost() - const { listing } = await chat(workspace('clean'), { saved: { effort: 'low' } }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6' }) - }) - - it('learns from a new Pi session standing in for a conversation whose file was gone', async () => { - const { chat } = await piHost() - const { listing } = await chat(workspace('clean'), { replacement: true }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6', effort: 'high' }) - }) -}) diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts new file mode 100644 index 00000000000..a0dbf15fa67 --- /dev/null +++ b/src/main/pi/rpc-message-dispatch.ts @@ -0,0 +1,112 @@ +import { + agentSessionFailureFact, + providerDiagnostic, + withProviderDiagnostic +} from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { applyPiRpcSessionOption } from './rpc-options' +import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' +import type { PiRpcSession, PiRpcSessionDeps } from './rpc-session' + +type DispatchInput = Parameters[0] + +function exited(): AgentSessionDispatchOutcome { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + agentName: 'Pi', + surface: 'rejection' + }) + } +} + +export async function dispatchPiRpcMessage( + session: PiRpcSession, + input: DispatchInput, + logger: PiRpcSessionDeps['logger'], + isCurrent: () => boolean +): Promise { + let prompt + try { + prompt = await preparePiRpcPrompt(input.body) + } catch (error) { + logger.warn('Pi prompt could not be prepared', { + scope: 'pi-prompt', + sessionId: input.sessionId, + error + }) + return { + state: 'rejected', + ...agentSessionFailureWords( + error instanceof PiRpcPromptError || error instanceof ClaudeDispatchContentError + ? error.failure + : agentSessionFailureFact('attachmentUnreadable'), + { agentName: 'Pi', surface: 'rejection' } + ) + } + } + const model = input.executionOptions?.model ?? session.initialOptions.model + if (!input.executionOptions) { + input.signal?.throwIfAborted() + return session.turns.submit( + input.clientMessageId, + input.requestedAt ?? Date.now(), + { ...prompt }, + input.beforeDispatch + ) + } + for (const key of ['model', 'effort'] as const) { + const value = + input.executionOptions?.[key] ?? + (key === 'model' || model === session.initialOptions.model + ? session.initialOptions[key] + : undefined) + if (value === undefined && session.optionProof.writes.has(key)) { + throw new AgentSessionOptionRejectedError( + new Error(`The default ${key} for this model is unknown`) + ) + } + if (value === undefined || session.optionProof.current.get(key) === value) { + continue + } + try { + await applyPiRpcSessionOption( + session.connection, + session.selected, + key, + value, + { signal: input.signal }, + session.optionProof + ) + } catch (error) { + input.signal?.throwIfAborted() + if (session.connection.closed) { + return exited() + } + if (error instanceof JsonlRpcResponseError) { + throw withProviderDiagnostic( + new AgentSessionOptionRejectedError(error), + providerDiagnostic(error.message, 'person') + ) + } + throw new AgentSessionOptionRejectedError(error) + } + } + input.signal?.throwIfAborted() + if (!isCurrent() || session.connection.closed) { + return exited() + } + return session.turns.submit( + input.clientMessageId, + input.requestedAt ?? Date.now(), + { ...prompt }, + input.beforeDispatch + ) +} diff --git a/src/main/pi/rpc-options.test.ts b/src/main/pi/rpc-options.test.ts index 5beaefb54ca..d15c602cf55 100644 --- a/src/main/pi/rpc-options.test.ts +++ b/src/main/pi/rpc-options.test.ts @@ -70,8 +70,8 @@ describe('Pi RPC session options', () => { effort: 'high' }) expect(rpc.request.mock.calls).toEqual([ - ['set_model', { provider: 'openai-codex', modelId: 'gpt-6.1-sol' }], - ['set_thinking_level', { level: 'high' }] + ['set_model', { provider: 'openai-codex', modelId: 'gpt-6.1-sol' }, {}], + ['set_thinking_level', { level: 'high' }, {}] ]) expect(() => parsePiModelOptionId('openai-codex')).toThrow() await expect(applyPiRpcSessionOption(rpc, selected, 'model', '/gpt-6.1-sol')).rejects.toThrow() diff --git a/src/main/pi/rpc-options.ts b/src/main/pi/rpc-options.ts index 4f75e064c6e..8f91dc3b0c0 100644 --- a/src/main/pi/rpc-options.ts +++ b/src/main/pi/rpc-options.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionCommandsResult, AgentSessionOptionsResult @@ -88,25 +89,49 @@ export async function readPiRpcSessionOptions( } } +export type PiRpcOptionProof = { current: Map; writes: Map } + export async function applyPiRpcSessionOption( rpc: PiRpcRequester, selected: Map, key: string, - value: string + value: string, + options: { timeoutMs?: number | null; signal?: AbortSignal } = {}, + proof?: PiRpcOptionProof ): Promise>> { + let command: string + let params: Record if (key === 'model') { const model = parsePiModelOptionId(value) modelSchema.parse({ provider: model.provider, id: model.modelId }) - await rpc.request('set_model', model) + command = 'set_model' + params = model } else if (key === 'effort') { if (!THINKING_LEVELS.some((level) => level === value)) { throw new Error(`Unsupported Pi thinking level: ${value}`) } - await rpc.request('set_thinking_level', { level: value }) + command = 'set_thinking_level' + params = { level: value } } else { throw new Error(`Unsupported Pi option: ${key}`) } + options.signal?.throwIfAborted() + const token = Symbol(key) + proof?.writes.set(key, token) + proof?.current.delete(key) + if (key === 'model') { + proof?.writes.set('effort', token) + proof?.current.delete('effort') + } + await waitForPromiseWithSignal( + rpc.request(command, params, { timeoutMs: options.timeoutMs }), + options.signal + ) + if (proof && proof.writes.get(key) !== token) { + throw new Error('Pi option write was superseded') + } selected.set(key, value) + proof?.current.set(key, value) return Object.fromEntries(selected) } diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index 762da44ac13..a86a3fdf5ff 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -15,11 +15,13 @@ import { PI_RPC_RUNTIME_METADATA } from './rpc-runtime-metadata' function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { const { deps } = context return new PiRpcSessionAdapter({ - resolveLaunch: composeStructuredLaunch( - PI_RPC_AGENT, - structuredAgentLaunchSources(context), - piRpcLaunchPart({}) - ), + resolveLaunch: + deps.resolvePiLaunch ?? + composeStructuredLaunch( + PI_RPC_AGENT, + structuredAgentLaunchSources(context), + piRpcLaunchPart({}) + ), ...(deps.openPiConnection ? { openConnection: deps.openPiConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), onLifecycle: context.deliverLifecycle, diff --git a/src/main/pi/rpc-session-adapter.test-fixture.ts b/src/main/pi/rpc-session-adapter.test-fixture.ts new file mode 100644 index 00000000000..b5d1d48dc40 --- /dev/null +++ b/src/main/pi/rpc-session-adapter.test-fixture.ts @@ -0,0 +1,224 @@ +import { expect, vi, type Mock } from 'vitest' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { PiRpcConnection } from './rpc-session' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { PiRpcSessionAdapter, type PiRpcSessionAdapterDeps } from './rpc-session-adapter' +import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' + +export const sessionId = 'session-timeline' +export const file = '/host/account/sessions/session.jsonl' +export const state = { + sessionFile: file, + isStreaming: false, + isCompacting: false, + model: { provider: 'anthropic', id: 'model-1' } +} +const cleanups: (() => Promise)[] = [] +export async function closePiRpcAdapterFixtures(): Promise { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + await closeProviderTimelineRigs() +} + +export class FakeConnection implements PiRpcConnection { + pid = 4123 + closed = false + rootVerdict: 'live' | 'unverifiable' | 'exited' = 'live' + processless = false + deferExit = false + closeError?: Error + lastCloseResult: PiRpcConnection['lastCloseResult'] = null + closeResult: Awaited> = { root: 'exited', tree: 'exited' } + readonly sent: JsonlRpcRecord[] = [] + readonly requests: string[] = [] + private readonly exitListeners: (() => void)[] = [] + requestOverride?: (command: string) => Promise | undefined + abortRequest?: () => void + constructor(readonly handlers: JsonlRpcAgentConnectionOptions) {} + async request(command: string): Promise { + this.requests.push(command) + const override = this.requestOverride?.(command) + if (override) { + return override + } + if (command === 'get_state') { + return state + } + if (command === 'get_available_models') { + return { models: [state.model] } + } + if (command === 'get_commands') { + return { commands: [{ name: 'help' }] } + } + if (command === 'set_thinking_level') { + throw new JsonlRpcResponseError(command, 'saved effort unavailable') + } + return {} + } + async send(frame: JsonlRpcRecord): Promise { + this.sent.push(frame) + } + async close(): Promise>> { + this.abortRequest?.() + this.closed = true + this.rootVerdict = this.closeResult.root + this.lastCloseResult = this.closeResult + if (this.closeResult.root === 'exited' && !this.deferExit) { + this.exit(this.closeError) + } + return this.closeResult + } + pauseReading(): void {} + resumeReading(): void {} + onExit(listener: () => void): void { + if (this.rootVerdict === 'exited') { + listener() + } else { + this.exitListeners.push(listener) + } + } + receive(frame: JsonlRpcRecord): void { + this.handlers.onRecord?.(frame) + } + exit(error = new Error('child exited')): void { + this.closed = true + this.rootVerdict = 'exited' + for (const listener of this.exitListeners.splice(0)) { + listener() + } + this.handlers.onExit?.(error, { + expected: false, + exit: { code: 1, signal: null, processless: false } + }) + } +} + +type PiLifecycleEvent = Parameters[0] + +/** The first `type` event the adapter reported; a missing one fails the test. */ +export function reportedEvent( + lifecycle: Mock, + type: T +): Extract { + const isType = (event: PiLifecycleEvent): event is Extract => + event.type === type + const found = lifecycle.mock.calls.map(([event]) => event).find(isType) + if (!found) { + throw new Error(`no ${type} event was reported`) + } + return found +} + +export async function setup( + options: Readonly> = {}, + eventSink?: (sink: StructuredAgentSessionEventSink) => StructuredAgentSessionEventSink +) { + const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) + const connections: FakeConnection[] = [] + const lifecycle = vi.fn() + const settled = vi.fn() + const idle = vi.fn() + const onSpawned = vi.fn(async (_process: AgentSessionProcessIdentity) => { + expect(connections.at(-1)?.requests).toEqual([]) + }) + const input: StructuredAgentSessionAcquireInput = { + identity: { + sessionId, + workspaceId: 'folder-1', + hostId: 'local', + agent: 'pi', + providerHandle: null + }, + fence: 7, + spawnToken: 'spawn-token', + options, + events: eventSink?.(rig.eventSink) ?? rig.eventSink, + onSpawned + } + const resolveLaunch = vi.fn(async (): Promise => ({ + command: '/host/bin/pi', + cwd: '/host/folder', + fullAccess: true, + previous: null + })) + const openConnection = vi.fn( + (_launch: ProviderProcessLaunch, handlers: JsonlRpcAgentConnectionOptions) => { + const connection = new FakeConnection(handlers) + connections.push(connection) + return connection + } + ) + const adapter = new PiRpcSessionAdapter({ + resolveLaunch, + readProcessStartTime: async () => 12345, + openConnection, + onLifecycle: lifecycle, + onSettled: settled, + onIdle: idle, + logger: { warn: vi.fn(), error: vi.fn() } + }) + const acquired = await adapter.acquire(input) + const started = await adapter.open({ + sessionId, + fence: 7, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) + lifecycle.mockClear() + const connection = connections[0] + if (!connection) { + throw new Error('connection missing') + } + cleanups.push(async () => { + await adapter.closeAll().catch(() => {}) + await adapter.drainObservedExits() + adapter.acknowledgeSessionRelease(sessionId) + }) + return { + adapter, + connection, + rig, + acquired, + started, + onSpawned, + lifecycle, + settled, + idle, + input, + resolveLaunch, + openConnection, + connections + } +} + +export async function restart( + h: Awaited>, + configure: (connection: FakeConnection) => void, + input: Partial = {} +) { + await h.adapter.closeSession(sessionId) + await h.adapter.drainObservedExits() + h.lifecycle.mockClear() + h.openConnection.mockImplementationOnce((_launch, handlers) => { + const connection = new FakeConnection(handlers) + configure(connection) + h.connections.push(connection) + return connection + }) + const acquired = await h.adapter.acquire({ ...h.input, fence: 8, ...input }) + const child = h.connections.at(-1) + if (!child) { + throw new Error('child missing') + } + return { acquired, child } +} diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 8c74e65eea1..557b6734330 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -1,185 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { - AgentSessionAcquisitionRootExitObservedError, - type StructuredAgentSessionAcquireInput -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - closeProviderTimelineRigs, - openProviderTimelineRig -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import type { JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' -import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { JsonlRpcResponseError } from '../jsonl-rpc/peer' -import type { PiRpcConnection } from './rpc-session' -import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + closePiRpcAdapterFixtures, + file, + sessionId, + setup +} from './rpc-session-adapter.test-fixture' import { agentSessionLaunchFolderMissing } from '../runtime/agent-session-launch-directory' -import { PiRpcSessionAdapter } from './rpc-session-adapter' -const sessionId = 'session-timeline' -const file = '/host/account/sessions/session.jsonl' -const state = { - sessionFile: file, - isStreaming: false, - isCompacting: false, - model: { provider: 'anthropic', id: 'model-1' } -} -const cleanups: (() => Promise)[] = [] -afterEach(async () => { - for (const cleanup of cleanups.splice(0)) { - await cleanup() - } - await closeProviderTimelineRigs() -}) - -class FakeConnection implements PiRpcConnection { - pid = 4123 - closed = false - rootVerdict: 'live' | 'unverifiable' | 'exited' = 'live' - processless = false - deferExit = false - lastCloseResult: PiRpcConnection['lastCloseResult'] = null - closeResult: Awaited> = { root: 'exited', tree: 'exited' } - readonly sent: JsonlRpcRecord[] = [] - readonly requests: string[] = [] - private readonly exitListeners: (() => void)[] = [] - requestOverride?: (command: string) => Promise | undefined - abortRequest?: () => void - constructor(readonly handlers: JsonlRpcAgentConnectionOptions) {} - async request(command: string): Promise { - this.requests.push(command) - const override = this.requestOverride?.(command) - if (override) { - return override - } - if (command === 'get_state') { - return state - } - if (command === 'get_available_models') { - return { models: [state.model] } - } - if (command === 'get_commands') { - return { commands: [{ name: 'help' }] } - } - if (command === 'set_thinking_level') { - throw new Error('saved effort unavailable') - } - return {} - } - async send(frame: JsonlRpcRecord): Promise { - this.sent.push(frame) - } - async close(): Promise>> { - this.abortRequest?.() - this.closed = true - this.rootVerdict = this.closeResult.root - this.lastCloseResult = this.closeResult - if (this.closeResult.root === 'exited' && !this.deferExit) { - this.exit() - } - return this.closeResult - } - pauseReading(): void {} - resumeReading(): void {} - onExit(listener: () => void): void { - if (this.rootVerdict === 'exited') { - listener() - } else { - this.exitListeners.push(listener) - } - } - receive(frame: JsonlRpcRecord): void { - this.handlers.onRecord?.(frame) - } - exit(error = new Error('child exited')): void { - this.closed = true - this.rootVerdict = 'exited' - for (const listener of this.exitListeners.splice(0)) { - listener() - } - this.handlers.onExit?.(error, { - expected: false, - exit: { code: 1, signal: null, processless: false } - }) - } -} - -async function setup( - options: Readonly> = {}, - eventSink?: (sink: StructuredAgentSessionEventSink) => StructuredAgentSessionEventSink -) { - const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) - const connections: FakeConnection[] = [] - const lifecycle = vi.fn(), - settled = vi.fn(), - idle = vi.fn() - const onSpawned = vi.fn(async (_process: AgentSessionProcessIdentity) => { - expect(connections.at(-1)?.requests).toEqual([]) - }) - const input: StructuredAgentSessionAcquireInput = { - identity: { - sessionId, - workspaceId: 'folder-1', - hostId: 'local', - agent: 'pi', - providerHandle: null - }, - fence: 7, - spawnToken: 'spawn-token', - options, - events: eventSink?.(rig.eventSink) ?? rig.eventSink, - onSpawned - } - const resolveLaunch = vi.fn(async () => ({ - command: '/host/bin/pi', - cwd: '/host/folder', - fullAccess: true, - previous: null - })) - const openConnection = vi.fn( - (_launch: ProviderProcessLaunch, handlers: JsonlRpcAgentConnectionOptions) => { - const connection = new FakeConnection(handlers) - connections.push(connection) - return connection - } - ) - const adapter = new PiRpcSessionAdapter({ - resolveLaunch, - readProcessStartTime: async () => 12345, - openConnection, - onLifecycle: lifecycle, - onSettled: settled, - onIdle: idle, - logger: { warn: vi.fn(), error: vi.fn() } - }) - const acquired = await adapter.acquire(input) - const connection = connections[0] - if (!connection) { - throw new Error('connection missing') - } - cleanups.push(async () => { - await adapter.closeAll().catch(() => {}) - await adapter.drainObservedExits() - adapter.acknowledgeSessionRelease(sessionId) - }) - return { - adapter, - connection, - rig, - acquired, - onSpawned, - lifecycle, - settled, - idle, - input, - resolveLaunch, - openConnection, - connections - } -} +afterEach(closePiRpcAdapterFixtures) describe('Pi RPC session ownership and delivery', () => { it('refuses a floating chat whose folder is gone with the reason the person reads', async () => { @@ -206,36 +38,13 @@ describe('Pi RPC session ownership and delivery', () => { await expect(started).rejects.toThrow('closed while starting') expect(h.connections).toHaveLength(1) await expect(h.adapter.acquire({ ...h.input, fence: 9 })).resolves.toMatchObject({ - link: { origin: 'created' } + process: { pid: 4123 } }) resolving.resolve(launch!) await Promise.resolve() expect(h.connections).toHaveLength(2) }) - it('kills the child and rejects a stalled startup when the host aborts it', async () => { - const h = await setup() - await h.adapter.closeSession(sessionId) - const controller = new AbortController() - const opened = Promise.withResolvers() - const reply = Promise.withResolvers() - h.openConnection.mockImplementationOnce((_launch, handlers) => { - const connection = new FakeConnection(handlers) - connection.requestOverride = (command) => - command === 'get_state' ? reply.promise : undefined - connection.abortRequest = () => reply.reject(new Error('closed while starting')) - h.connections.push(connection) - opened.resolve(connection) - return connection - }) - const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal }) - const child = await opened.promise - await Promise.resolve() - controller.abort(new Error('Pi closed while starting')) - await expect(started).rejects.toThrow('closed while starting') - expect(child.rootVerdict).toBe('exited') - }) - it('retains final events when exit publication times out under journal backpressure', async () => { let blocked = true const h = await setup({}, (sink) => ({ @@ -278,7 +87,7 @@ describe('Pi RPC session ownership and delivery', () => { await h.adapter.closeSession(sessionId) h.adapter.acknowledgeSessionRelease(sessionId) await expect(h.adapter.acquire({ ...h.input, fence: 8 })).resolves.toMatchObject({ - link: { origin: 'created' } + process: { pid: 4123 } }) let drained = false const delivery = h.adapter.drainObservedExits().then(() => { @@ -310,13 +119,14 @@ describe('Pi RPC session ownership and delivery', () => { spawnToken: 'spawn-token' }) expect(h.acquired.process).toEqual(h.onSpawned.mock.calls[0]?.[0]) - expect(h.acquired.link).toMatchObject({ + expect(h.acquired.link).toBeUndefined() + expect(h.started.link).toMatchObject({ handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, origin: 'created', mintedAtFence: 7 }) - expect(h.adapter.readOptionRestoreFailures(sessionId)).toEqual(['effort', 'unknown']) - expect(h.connection.requests).toContain('get_commands') + expect(h.connection.requests).not.toContain('set_thinking_level') + await vi.waitFor(() => expect(h.connection.requests).toContain('get_commands')) }) it('admits a send before Pi confirms it and settles only after journal acceptance', async () => { @@ -342,6 +152,82 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connection.sent[0]).toMatchObject({ type: 'prompt', message: 'hello' }) }) + it('applies each sent model before effort and before prompting Pi', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + const result = await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'choice-send', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'chosen' }] }, + executionOptions: { model: 'anthropic/model-2', effort: 'low' } + }) + expect(result).toEqual({ state: 'admitted' }) + expect(h.connection.requests.slice(-2)).toEqual(['set_model', 'set_thinking_level']) + expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }]) + }) + + it('rejects a known-unsent prompt when an option times out and preserves Stop reason', async () => { + const h = await setup() + const timeout = new Error('Pi did not answer set_model') + h.connection.requestOverride = (command) => + command === 'set_model' ? Promise.reject(timeout) : undefined + const input = { + sessionId, + fence: 7, + clientMessageId: 'unsent', + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + }, + executionOptions: { model: 'anthropic/model-2' } + } + await expect(h.adapter.dispatch(input)).rejects.toMatchObject({ + name: 'AgentSessionOptionRejectedError' + }) + expect(h.connection.sent).toEqual([]) + const controller = new AbortController() + controller.abort(timeout) + await expect(h.adapter.dispatch({ ...input, signal: controller.signal })).rejects.toBe(timeout) + }) + + it('re-establishes opening A after an unanswered unary B and ignores B’s late answer', async () => { + const h = await setup() + const unanswered = Promise.withResolvers() + const controller = new AbortController() + h.connection.requestOverride = (command) => + command === 'set_model' ? unanswered.promise : undefined + const pick = h.adapter.setOption({ + sessionId, + fence: 7, + key: 'model', + value: 'anthropic/model-2', + signal: controller.signal + }) + await vi.waitFor(() => expect(h.connection.requests).toContain('set_model')) + controller.abort(new Error('Stop')) + await expect(pick).rejects.toThrow('Stop') + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + await expect( + h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'restore', + executionOptions: { model: 'anthropic/model-1', effort: 'low' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'A again' }] } + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(h.connection.requests.filter((command) => command === 'set_model')).toHaveLength(2) + unanswered.resolve({}) + await Promise.resolve() + expect((await h.adapter.readOptions({ sessionId, fence: 7 })).current.model).toBe( + 'anthropic/model-1' + ) + }) + it('rejects stale fences and a Stop for a different turn before sending abort', async () => { const h = await setup() await expect( @@ -572,29 +458,3 @@ describe('Pi RPC session ownership and delivery', () => { expect(await other.adapter.closeSession(sessionId)).toBe(false) }) }) - -describe('Pi RPC start without a model', () => { - it('reports a start that listed no model as signed out, for that child only', async () => { - const h = await setup() - expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() - await h.adapter.closeSession(sessionId) - h.openConnection.mockImplementationOnce((_launch, handlers) => { - const connection = new FakeConnection(handlers) - // What a signed-out Pi lists (captured from Pi 1.0.4 in __fixtures__/signed-out.jsonl). - connection.requestOverride = (command) => - command === 'get_available_models' ? Promise.resolve({ models: [] }) : undefined - h.connections.push(connection) - return connection - }) - await h.adapter.acquire({ ...h.input, fence: 8 }) - expect(h.adapter.startUnavailable(sessionId)).toEqual({ reason: 'notSignedIn' }) - // Its root gone while its output still drains: that child says nothing more. - const child = h.connections.at(-1)! - child.rootVerdict = 'exited' - expect(child.closed).toBe(false) - expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() - child.rootVerdict = 'live' - await h.adapter.closeSession(sessionId) - expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() - }) -}) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 11452053b28..8afdfbd1cb4 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -1,8 +1,5 @@ import { randomUUID } from 'node:crypto' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import type { AgentSessionUnavailable } from '../../shared/agent-session-availability' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { AgentSessionPreSpawnError, AgentSessionAcquisitionRootExitObservedError, @@ -16,12 +13,11 @@ import { providerSpawnedProcessIdentity } from '../provider-process/provider-spa import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts' import { compactPiRpcSession } from './rpc-compaction' import { buildPiRpcLaunch } from './rpc-launch' -import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' +import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session' -import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' +import { dispatchPiRpcMessage } from './rpc-message-dispatch' import { applyPiRpcSessionOption } from './rpc-options' import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' -import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' export type PiRpcSessionAdapterDeps = PiRpcSessionDeps & { resolveLaunch: (input: { @@ -81,7 +77,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } const spec = buildPiRpcLaunch(launch) const fresh = !launch.sessionFile && !launch.forkFile - session = new PiRpcSession(input, randomUUID(), spec, this.deps, fresh) + session = new PiRpcSession(input, randomUUID(), spec, this.deps, launch, fresh) this.sessions.set(id, session) this.starts.track(attempt, session.connection) const spawned = providerSpawnedProcessIdentity( @@ -93,15 +89,12 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { await spawned.onSpawned(session.connection.pid) } const process = await spawned.read(session.connection.pid) - const file = await waitForPromiseWithSignal(session.start(), attempt.signal) if (session.connection.closed || attempt.signal.aborted) { throw new Error('Pi exited while starting') } return { process, - acquisitionGeneration: session.generation, - link: piRpcProviderLink(launch, file, input.fence, randomUUID(), Date.now()), - ...(session.startListing ? { catalogListing: session.startListing } : {}) + acquisitionGeneration: session.generation } } catch (error) { if (!session) { @@ -125,32 +118,26 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } } + open: NonNullable = async (input) => { + const session = this.sessions.get(input.sessionId) + if ( + !session || + session.input.fence !== input.fence || + session.generation !== input.acquisitionGeneration || + session.connection.closed + ) { + throw new Error('Pi child changed before it opened') + } + return session.open(input.signal) + } + dispatch: StructuredAgentSessionAdapter['dispatch'] = async (input) => { const session = this.session(input.sessionId, input.fence) - let prompt - try { - prompt = await preparePiRpcPrompt(input.body) - } catch (error) { - this.deps.logger.warn('Pi prompt could not be prepared', { - scope: 'pi-prompt', - sessionId: input.sessionId, - error - }) - return { - state: 'rejected', - ...agentSessionFailureWords( - error instanceof PiRpcPromptError || error instanceof ClaudeDispatchContentError - ? error.failure - : agentSessionFailureFact('attachmentUnreadable'), - { agentName: 'Pi', surface: 'rejection' } - ) - } - } - return session.turns.submit( - input.clientMessageId, - input.requestedAt ?? Date.now(), - { ...prompt }, - input.beforeDispatch + return dispatchPiRpcMessage( + session, + input, + this.deps.logger, + () => this.sessions.get(input.sessionId) === session ) } @@ -223,27 +210,24 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { ) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => { const session = this.session(input.sessionId, input.fence) - return applyPiRpcSessionOption(session.connection, session.selected, input.key, input.value) + return applyPiRpcSessionOption( + session.connection, + session.selected, + input.key, + input.value, + { signal: input.signal }, + session.optionProof + ) } readOptions: NonNullable = async (input) => this.session(input.sessionId, input.fence).readOptions() - readCommands = (id: string) => this.sessions.get(id)?.commands - readOptionRestoreFailures(id: string): readonly string[] { - return this.sessions.get(id)?.skipped ?? [] - } + readCommands = (id: string) => this.sessions.get(id)?.startup.commands holdsDispatch(id: string): boolean { return this.sessions.get(id)?.turns.holdsDispatch ?? false } - /** Started with no model listed: Pi keeps a placeholder, so every prompt this child takes fails - * as not signed in, even after a sign-in, which only a new Pi reads. */ - startUnavailable(id: string): AgentSessionUnavailable | undefined { + holdsLiveProviderProcess(id: string, generation: string): boolean { const session = this.sessions.get(id) - return session && - !session.connection.closed && - session.connection.rootVerdict !== 'exited' && - session.options?.models.length === 0 - ? { reason: 'notSignedIn' } - : undefined + return session?.generation === generation && !session.connection.closed } async closeSession(id: string, requested = true): Promise { @@ -294,7 +278,12 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } private session(id: string, fence: number): PiRpcSession { const session = this.sessions.get(id) - if (!session || session.input.fence !== fence || session.connection.closed) { + if ( + !session || + session.input.fence !== fence || + session.connection.closed || + !session.openedLink + ) { throw new Error('Pi session is not live under this fence') } return session diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts new file mode 100644 index 00000000000..6be7a232f95 --- /dev/null +++ b/src/main/pi/rpc-session-startup.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + closePiRpcAdapterFixtures, + file, + reportedEvent, + restart, + sessionId, + setup, + state +} from './rpc-session-adapter.test-fixture' + +function openAt8(h: Awaited>, acquisitionGeneration: string) { + return h.adapter.open({ + sessionId, + fence: 8, + acquisitionGeneration, + signal: new AbortController().signal + }) +} + +afterEach(closePiRpcAdapterFixtures) + +describe('Pi host awaited open', () => { + it('lets the host close a child held in the state request without a late open', async () => { + const h = await setup() + const stateReply = Promise.withResolvers() + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_state' ? stateReply.promise : undefined + connection.abortRequest = () => stateReply.reject(new Error('closed while starting')) + }) + const controller = new AbortController() + const opening = h.adapter + .open({ + sessionId, + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: controller.signal + }) + .catch((error: unknown) => error) + expect(child.requests).toEqual(['get_state']) + controller.abort() + await h.adapter.closeSession(sessionId) + expect(await opening).toBeInstanceOf(Error) + expect(child.rootVerdict).toBe('exited') + expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + }) + + it('fails an open if its lane fails while the state request is pending', async () => { + const h = await setup() + const stateReply = Promise.withResolvers() + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_state' ? stateReply.promise : undefined + }) + const opening = h.adapter.open({ + sessionId, + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + child.handlers.onClose?.(new Error('lane failed during startup')) + stateReply.resolve(state) + await expect(opening).rejects.toThrow('lane failed during startup') + }) + + it('is ready once Pi reports its state; the listing follows, and an empty one fails nothing', async () => { + const h = await setup() + const listing = Promise.withResolvers() + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_available_models' ? listing.promise : undefined + }) + await expect(openAt8(h, acquired.acquisitionGeneration)).resolves.toMatchObject({ + reportedOptions: { model: 'anthropic/model-1' } + }) + expect(h.lifecycle).not.toHaveBeenCalled() + listing.resolve({ models: [] }) + await vi.waitFor(() => expect(reportedEvent(h.lifecycle, 'options-reported').fence).toBe(8)) + expect(child.closed).toBe(false) + await vi.waitFor(() => + expect(h.adapter.readCommands(sessionId)).toMatchObject([{ name: 'help' }]) + ) + }) + + it('fails the start when Pi runs the unknown provider', async () => { + const h = await setup() + const unknown = { ...state, model: { provider: 'unknown', id: 'unknown' } } + const { acquired } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_state' ? Promise.resolve(unknown) : undefined + }) + await expect(openAt8(h, acquired.acquisitionGeneration)).rejects.toMatchObject({ + reason: 'notSignedIn' + }) + }) + + it('learns the immutable opening model and effort while listing spans a message switch', async () => { + const h = await setup() + const listing = Promise.withResolvers() + let current = { ...state, thinkingLevel: 'low' } + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => { + if (command === 'get_available_models') { + return listing.promise + } + if (command === 'get_state') { + return Promise.resolve(current) + } + if (command === 'set_model') { + current = { ...current, model: { provider: 'anthropic', id: 'model-2' } } + } + if (command === 'set_thinking_level') { + current = { ...current, thinkingLevel: 'high' } + return Promise.resolve({}) + } + return undefined + } + }) + await openAt8(h, acquired.acquisitionGeneration) + await h.adapter.dispatch({ + sessionId, + fence: 8, + clientMessageId: 'switch', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, + executionOptions: { model: 'anthropic/model-2', effort: 'high' } + }) + listing.resolve({ models: [state.model, current.model] }) + await vi.waitFor(() => + expect(reportedEvent(h.lifecycle, 'options-reported')).toMatchObject({ + reportedOptions: { model: 'anthropic/model-2', effort: 'high' }, + catalogListing: { configuredDefault: { modelId: 'anthropic/model-1', effort: 'low' } } + }) + ) + expect(child.sent).toMatchObject([{ type: 'prompt', message: 'go' }]) + }) + + it('teaches the account default only from a new session started with no pick', async () => { + const h = await setup() + await vi.waitFor(() => + expect( + reportedEvent(h.lifecycle, 'options-reported').catalogListing?.configuredDefault + ).toEqual({ modelId: 'anthropic/model-1', effort: null }) + ) + + h.resolveLaunch.mockResolvedValueOnce({ ...(await h.resolveLaunch()), sessionFile: file }) + const { acquired } = await restart(h, () => {}) + await openAt8(h, acquired.acquisitionGeneration) + await vi.waitFor(() => + expect(reportedEvent(h.lifecycle, 'options-reported').catalogListing).toBeDefined() + ) + expect(reportedEvent(h.lifecycle, 'options-reported').catalogListing).not.toHaveProperty( + 'configuredDefault' + ) + }) +}) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts new file mode 100644 index 00000000000..518ebe4650b --- /dev/null +++ b/src/main/pi/rpc-session-startup.ts @@ -0,0 +1,147 @@ +import { randomUUID } from 'node:crypto' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import type { + AgentSessionOptionsResult, + AgentSessionSlashCommand +} from '../../shared/agent-session-wire' +import { + AgentSessionAcquisitionRefusal, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { + unpickedSessionConfiguredChoice, + withLiveCatalogListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' +import { piModelOptionId, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' +import { piRpcStateSchema, type PiRpcState } from './rpc-protocol' +import type { PiRpcConnection, PiRpcSessionDeps } from './rpc-session' + +export class PiRpcSessionStartup { + commands?: AgentSessionSlashCommand[] + + constructor( + private readonly input: StructuredAgentSessionAcquireInput, + private readonly connection: PiRpcConnection, + private readonly deps: PiRpcSessionDeps, + /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ + private readonly resolvesConfig: boolean, + private readonly setState: (state: PiRpcState) => void, + private readonly failedCause: () => Error | undefined + ) {} + + /** Ready once Pi reports its state: only an unknown provider fails the start. */ + async run( + launch: PiRpcResolvedLaunch, + signal: AbortSignal + ): Promise { + const answer = await this.wait(this.connection.request('get_state', {}), signal) + this.assertLive(signal) + const state = piRpcStateSchema.parse(answer) + if (state.model?.provider === 'unknown') { + throw this.notSignedIn() + } + const link = piRpcProviderLink( + launch, + state.sessionFile, + this.input.fence, + randomUUID(), + Date.now() + ) + this.setState(state) + const current: AgentSessionOptionsResult['current'] = { + model: state.model ? piModelOptionId(state.model.provider, state.model.id) : '', + ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}) + } + this.assertLive(signal) + current.confirmed = [...(current.model ? ['model'] : []), ...(current.effort ? ['effort'] : [])] + return { link, reportedOptions: current } + } + + /** After the open: the listing and the `/` commands reach the host as later reports, and a read + * that fails only leaves the chat without them. */ + async readAfterOpen( + generation: string, + opening: AgentSessionOptionsResult['current'] + ): Promise { + const options = await this.readListing() + if (options && !this.failedCause() && !this.connection.closed) { + // What it started on names its config's default once; a later switch teaches nothing. + const { catalogListing } = withLiveCatalogListing( + options, + unpickedSessionConfiguredChoice({ + resolvesConfig: this.resolvesConfig, + picked: new Set(Object.keys(this.input.options ?? {})), + models: options.models, + current: opening + }) + ) + this.deps.onLifecycle({ + type: 'options-reported', + sessionId: this.input.identity.sessionId, + fence: this.input.fence, + acquisitionGeneration: generation, + reportedOptions: options.current, + ...(catalogListing ? { catalogListing } : {}) + }) + } + await this.readCommands() + } + + private async readListing(): Promise { + try { + return await readPiRpcSessionOptions(this.connection) + } catch (error) { + this.warn('Pi options could not be read', 'pi-options', error) + return undefined + } + } + + private async readCommands(): Promise { + try { + const result = await readPiRpcCommands(this.connection) + if (this.failedCause() || this.connection.closed) { + return + } + this.commands = result.commands + this.input.events?.publish() + } catch (error) { + this.warn('Pi commands could not be read', 'pi-commands', error) + } + } + + private assertLive(signal: AbortSignal): void { + const failure = this.failedCause() + if (failure) { + throw failure + } + if (signal.aborted || this.connection.closed) { + throw new Error('Pi closed while starting') + } + } + + private wait(promise: Promise, signal: AbortSignal): Promise { + return waitForPromiseWithSignal(promise, signal) + } + + private notSignedIn(): AgentSessionAcquisitionRefusal { + return withProviderDiagnostic( + new AgentSessionAcquisitionRefusal( + 'Pi has no authenticated model provider available.', + 'notSignedIn' + ), + providerDiagnostic('Pi has no authenticated model provider available.', 'person') + ) + } + + private warn(message: string, scope: string, error: unknown, fields = {}): void { + this.deps.logger.warn(message, { + scope, + sessionId: this.input.identity.sessionId, + error, + ...fields + }) + } +} diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index ae693653df4..9bb1e6cc3a6 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -1,7 +1,8 @@ -import type { - AgentSessionOptionsResult, - AgentSessionSlashCommand -} from '../../shared/agent-session-wire' +import { + agentSessionFailureFact, + providerDiagnostic, + providerDiagnosticOf +} from '../../shared/agent-session-failure' import type { StructuredAgentSessionAcquireInput, StructuredAgentSessionLifecycleEvent, @@ -15,15 +16,15 @@ import { type JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' import { PiRpcTurns } from './rpc-turns' import { PiRpcDialogCallbacks } from './rpc-dialog-callbacks' -import { piRpcStateSchema } from './rpc-protocol' -import { applyPiRpcSessionOption, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' -import { - unpickedSessionConfiguredChoice, - withLiveCatalogListing, - type AgentModelCatalogLiveListing -} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import { readPiRpcSessionOptions } from './rpc-options' +import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { PiRpcSessionStartup } from './rpc-session-startup' export type PiRpcConnection = Pick< JsonlRpcAgentConnection, @@ -62,10 +63,10 @@ export class PiRpcSession { readonly turns: PiRpcTurns readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() - private startCatalogListing?: AgentModelCatalogLiveListing - readonly skipped: string[] = [] - commands?: AgentSessionSlashCommand[] - options?: AgentSessionOptionsResult + readonly optionProof = { current: new Map(), writes: new Map() } + readonly startup: PiRpcSessionStartup + initialOptions: Readonly> = {} + openedLink: AgentSessionProviderHandleLink | null = null requestedClose = false private publishedExit = false private failedCause?: Error @@ -77,6 +78,7 @@ export class PiRpcSession { readonly generation: string, launch: ProviderProcessLaunch, private readonly deps: PiRpcSessionDeps, + private readonly resolvedLaunch: PiRpcResolvedLaunch, /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ readonly resolvesConfig = false ) { @@ -125,7 +127,6 @@ export class PiRpcSession { this.connection = ( deps.openConnection ?? ((spec, handlers) => new JsonlRpcAgentConnection(spec, handlers)) )(launch, { - ...(input.onOutput ? { onOutput: input.onOutput } : {}), onRecord: (frame) => frame.type === 'extension_ui_request' ? this.dialogs.receive(frame) @@ -145,55 +146,36 @@ export class PiRpcSession { .finally(() => this.releaseAfterExit?.()) } }) + this.startup = new PiRpcSessionStartup( + input, + this.connection, + deps, + resolvesConfig, + (state) => { + this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) + }, + () => this.failedCause + ) } - async start(): Promise { - const state = piRpcStateSchema.parse(await this.connection.request('get_state')) - this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) - // Keys a restore sent, whether or not Pi took them. - const picked = new Set() - for (const [key, value] of Object.entries(this.input.options ?? {})) { - if (!['model', 'effort'].includes(key)) { - this.skipped.push(key) - continue - } - picked.add(key) - try { - await applyPiRpcSessionOption(this.connection, this.selected, key, value) - } catch (error) { - this.skipped.push(key) - this.deps.logger.warn('Pi rejected a saved option', { - scope: 'pi-option-restore', + async open(signal: AbortSignal): Promise { + const result = await this.startup.run(this.resolvedLaunch, signal) + this.initialOptions = { + ...(result.reportedOptions.model ? { model: result.reportedOptions.model } : {}), + ...(result.reportedOptions.effort ? { effort: result.reportedOptions.effort } : {}) + } + this.openedLink = result.link ?? null + this.optionProof.current = new Map(Object.entries(this.initialOptions)) + void this.startup + .readAfterOpen(this.generation, result.reportedOptions) + .catch((error: unknown) => + this.deps.logger.warn('Pi startup reports failed', { + scope: 'pi-startup-reports', sessionId: this.input.identity.sessionId, - key, error }) - } - } - this.options = await readPiRpcSessionOptions(this.connection) - this.startCatalogListing = withLiveCatalogListing( - this.options, - unpickedSessionConfiguredChoice({ - resolvesConfig: this.resolvesConfig, - picked, - ...this.options - }) - ).catalogListing - try { - this.commands = (await readPiRpcCommands(this.connection)).commands - } catch (error) { - this.deps.logger.warn('Pi commands could not be read', { - scope: 'pi-commands', - sessionId: this.input.identity.sessionId, - error - }) - } - return state.sessionFile - } - - /** What this child listed at its start, with what its config resolved; the host saves it once. */ - get startListing(): AgentModelCatalogLiveListing | undefined { - return this.startCatalogListing + ) + return result } /** What the child reports now; only the start says what its config resolved. */ @@ -214,6 +196,9 @@ export class PiRpcSession { sessionId: this.input.identity.sessionId, error }) + if (!this.openedLink) { + return + } void this.close(false) .then((result) => { if (result.root !== 'exited') { @@ -256,6 +241,7 @@ export class PiRpcSession { return } this.publishedExit = true + this.failedCause ??= error this.dialogs.cancelAll() this.turns.end() this.lane.finalize() @@ -281,6 +267,7 @@ export class PiRpcSession { } finally { clearTimeout(timer) } + const cause = this.failedCause ?? error this.deps.onLifecycle({ type: 'ended', sessionId: this.input.identity.sessionId, @@ -288,7 +275,15 @@ export class PiRpcSession { acquisitionGeneration: this.generation, observedAt: Date.now(), cause: this.requestedClose ? 'requested-close' : 'unexpected-exit', - reason: (this.failedCause ?? error).message + reason: cause.message, + failure: agentSessionFailureFact('providerExited', { + detail: + providerDiagnosticOf(cause) ?? + providerDiagnosticOf(error) ?? + (cause instanceof JsonlRpcResponseError + ? providerDiagnostic(cause.message, 'person') + : undefined) + }) }) } diff --git a/src/main/provider-process/managed-provider-process-output.test.ts b/src/main/provider-process/managed-provider-process-output.test.ts deleted file mode 100644 index 8a140eec29e..00000000000 --- a/src/main/provider-process/managed-provider-process-output.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { PassThrough } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' -import { spawnManagedProviderProcess } from './managed-provider-process' - -function launchWithOutput(onOutput: () => void) { - const child = Object.assign(createFakePipedChild(), { - pid: 9_999_999, - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn(() => true) - }) - const managed = spawnManagedProviderProcess( - { command: 'fixture-provider', args: [] }, - { - spawnImpl: () => child, - platform: 'win32', - site: 'fixture-provider-teardown', - onOutput - } - ) - return { child, managed } -} - -const tick = (): Promise => new Promise((resolve) => setImmediate(resolve)) - -describe('managed provider process output', () => { - it('reports stderr and stdout chunks as output', async () => { - const onOutput = vi.fn() - const { child } = launchWithOutput(onOutput) - child.stdout.on('data', () => {}) - await tick() - - child.stderr.write('migrating\n') - child.stdout.write('{"id":1}\n') - await tick() - - expect(onOutput).toHaveBeenCalledTimes(2) - }) - - it('never starts stdout before its reader subscribes, so a late reader loses nothing', async () => { - const onOutput = vi.fn() - const { child } = launchWithOutput(onOutput) - child.stdout.write('early frame\n') - await tick() - expect(onOutput).not.toHaveBeenCalled() - - const read: string[] = [] - child.stdout.setEncoding('utf8').on('data', (chunk: string) => read.push(chunk)) - await tick() - - expect(read.join('')).toBe('early frame\n') - expect(onOutput).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts deleted file mode 100644 index bef2bc424ef..00000000000 --- a/src/main/runtime/agent-session-record-options.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' -import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' -import { - openTestAgentSessionRecordStore, - seedTestAgentSessionRecordStore -} from './agent-session-record-store-test-harness' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { AcpStructuredOptions } from '../acp/acp-structured-options' -import { NewSessionResponseSchema } from '../acp/generated/acp-protocol.generated' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-options' -let directory: string - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-options-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -it('fails option hydration before ownership can be proved', async () => { - await expect( - readNativeSessionOptions({ - adapter: { - readOptions: async () => { - throw new Error('model list unavailable') - } - }, - sessionId: SESSION, - fence: 2 - }) - ).rejects.toThrow('model list unavailable') -}) - -it('drops provider-rejected persisted options before the next owner proof', async () => { - await expect( - readNativeSessionOptions({ - adapter: { - readOptions: async () => ({ models: [], current: { model: 'provider-model' } }), - readOptionRestoreFailures: () => ['permissionMode'] - }, - sessionId: SESSION, - fence: 2, - priorOptions: { permissionMode: 'retired-mode', other: 'keep' } - }) - ).resolves.toEqual({ model: 'provider-model', other: 'keep' }) -}) - -it('omits empty model values from other option producers', async () => { - await expect( - readNativeSessionOptions({ - adapter: { readOptions: async () => ({ models: [], current: { model: '', effort: 'off' } }) }, - sessionId: SESSION, - fence: 2, - priorOptions: { model: 'old-model', other: 'keep' } - }) - ).resolves.toEqual({ effort: 'off', other: 'keep' }) -}) - -it('durably replaces model-less ACP options when a later report reaches the option writer', async () => { - const response = NewSessionResponseSchema.parse( - JSON.parse( - readFileSync( - new URL('../acp/fixtures/omp-v17-windows-new-no-model.json', import.meta.url), - 'utf8' - ) - ) - ) - const reader = new AcpStructuredOptions() - reader.adoptSession(response) - const fixture = agentSessionRecordFixture() - const record = { - ...fixture, - provider: 'omp', - options: reader.reported(), - providerHandleChain: fixture.providerHandleChain.map((link) => ({ - ...link, - handle: { transport: 'acp', agent: 'omp', nativeId: response.sessionId } - })) - } - await seedTestAgentSessionRecordStore(directory, { records: [record] }) - const store = await openTestAgentSessionRecordStore(directory) - expect(store.getRecord(record.sessionId)?.options).toEqual({ effort: 'off' }) - - reader.adoptConfigOptions([ - ...(response.configOptions ?? []), - { - id: 'model', - name: 'Model', - category: 'model', - type: 'select', - currentValue: 'reported-model', - options: [{ value: 'reported-model', name: 'Reported Model' }] - } - ]) - const options = await readNativeSessionOptions({ - adapter: { readOptions: async () => reader.read() }, - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - priorOptions: store.getRecord(record.sessionId)?.options - }) - if (!options) { - throw new Error('The later ACP report must provide options') - } - await store.replaceSessionOptions({ - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - options, - now: NOW - }) - const reopened = await openTestAgentSessionRecordStore(directory) - expect(reopened.getRecord(record.sessionId)?.options).toEqual({ - model: 'reported-model', - effort: 'off' - }) -}) - -it('persists resumed provider options atomically with owner proof', async () => { - const store = await openTestAgentSessionRecordStore(directory) - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - expectedFence: null, - spawnToken: 'spawn-options', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'new session' }, - operation: { - callerKey: 'client-1', - operationId: '1800000000000-00000000000000000000000000000000', - fingerprint: 'options-create' - }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1, - spawnToken: 'spawn-options' - }, - now: NOW - }) - const options = await readNativeSessionOptions({ - adapter: { - readOptions: async () => ({ - models: [], - current: { model: 'gpt-tui', effort: 'low' } - }) - }, - sessionId: SESSION, - fence - }) - await store.proveOwner({ - sessionId: SESSION, - fence, - link: { - linkId: 'codex-options-1', - handle: codexProviderHandle('thread-options'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW, - ...(options ? { options } : {}) - }) - - const reopened = await openTestAgentSessionRecordStore(directory) - expect(reopened.getRecord(SESSION)?.options).toEqual({ model: 'gpt-tui', effort: 'low' }) -}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 6115678b5b7..849096dfcea 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -214,20 +214,16 @@ export class AgentSessionRecordStore { link?: AgentSessionProviderHandleLink now: number leaseTtlMs?: number - options?: Readonly> }): Promise { - return this.mutate(args.sessionId, (record) => { - const proved = proveAgentSessionOwner({ + return this.mutate(args.sessionId, (record) => + proveAgentSessionOwner({ record, fence: args.fence, ...(args.link ? { link: args.link } : {}), now: args.now, leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS }) - return args.options - ? replaceAgentSessionRecordOptions(proved, { ...args, options: args.options }) - : proved - }) + ) } /** Settle the failed attach and its reservation in one durable transaction. */ diff --git a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts index 528d8a8b7c7..0d20297fa9e 100644 --- a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts +++ b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts @@ -85,7 +85,8 @@ export function fakeClaude(providerSession: string) { stopTask: async (taskId) => { connection.calls.push({ subtype: 'stop_task', params: { taskId } }) }, - send: async (message) => { + send: async (message, beforeDispatch) => { + await beforeDispatch?.() connection.sent.push(message) if (message.type === 'user') { handlers.onMessage?.({ ...message, uuid: 'user-1' }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 0aad485b09b..7b25402fd62 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -535,7 +535,8 @@ describe('a structured Claude session over agentSession.*', () => { return false } // Claude takes the message but crashes before echoing it. - connection.send = async (message) => { + connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() connection.sent.push(message) } const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 5de41a0a3a4..b16861c6b03 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -128,20 +128,27 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) expect(claude.child(SESSION).launch.options).toMatchObject({ model: 'sonnet', + effort: 'high', permissionMode: 'acceptEdits' }) // The record keeps the saved choices through the start. await ready(host) expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }) expect(record(host)?.permissionMode).toBe('accept-edits') - expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual([]) expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) expect(claude.child(SESSION).calls).not.toContain('set_model') expect(claude.child(SESSION).calls).not.toContain('set_permission_mode') + claude.child(SESSION).connection.getSettings = async () => ({ + applied: { effort: 'high' }, + effective: { effortLevel: 'low' } + }) + await host.readOptions(SESSION) await send(host, 'hello') await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(claude.child(SESSION).calls).not.toContain('set_model') + expect(claude.child(SESSION).calls).not.toContain('apply_flag_settings') }) it('keeps the launched saved choice when the user later changes a different option', async () => { @@ -225,6 +232,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(claude.child(SESSION).launch.options).toMatchObject({ sessionId: freshProviderSessionId, model: 'sonnet', + effort: 'high', permissionMode: 'acceptEdits' }) expect(claude.child(SESSION).launch.options.resume).toBeUndefined() diff --git a/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts new file mode 100644 index 00000000000..e7efee5de51 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts @@ -0,0 +1,87 @@ +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { + createRestTestRig, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { HOST_TEST_NOW } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { RpcContext } from '../core' +import { methodNamed, rpcContext, runtimeStub } from './agent-launch.test-fixture' + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') + +// The phone: it renders either surface and sends a new chat's first message itself. +const PHONE: Partial = { + clientKind: 'mobile', + pairedDeviceId: 'device-1', + clientCapabilities: [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ] +} +// The `orca` CLI carries no capability list. +const CLI: Partial = {} +const SESSION = 'codex_9b1deb4d_3b7d_4bad_9bdd_2b0d7b3dcb6d' + +let rig: RestTestRig +beforeEach(async () => { + // The launch mints its operation id from the wall clock; the rig's host reads a fixed one. + vi.useFakeTimers({ now: HOST_TEST_NOW, toFake: ['Date'] }) + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + setStructuredAgentSessionHost(rig.host) +}) +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() + vi.useRealTimers() +}) + +function launch(caller: Partial, prompt?: string) { + const runtime = { + ...runtimeStub(), + resolveStructuredAgentSessionCreateIntent: vi.fn(async (params: { envelope: unknown }) => ({ + envelope: params.envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, + options: { model: 'gpt-5.6-sol', effort: 'medium' }, + runtimeKind: 'native' + })), + publishStructuredAgentSessionTab: vi.fn(async () => {}) + } + const params = AGENT_LAUNCH.params.parse({ + agent: 'codex', + target: { kind: 'existing', worktree: 'id:wt-7' }, + sessionId: SESSION, + ...(prompt ? { prompt: { text: prompt, delivery: 'submit' } } : {}) + }) + return AGENT_LAUNCH.handler(params, rpcContext(runtime, caller)) +} + +it.each([ + { name: 'a bare launch from the phone waits', caller: PHONE, startsAtCreation: false }, + { + name: 'a launch from the phone with a prompt starts', + caller: PHONE, + prompt: 'fix the build', + startsAtCreation: true + }, + { name: 'a bare launch from the CLI starts', caller: CLI, startsAtCreation: true } +])('$name', async ({ caller, prompt, startsAtCreation }) => { + const result = await launch(caller, prompt) + + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: SESSION }) + expect(rig.store.getRecord(SESSION)).toBeDefined() + // At rest, nothing is spawned until the chat's first message. + expect(rig.adapter.acquire.mock.calls.length > 0).toBe(startsAtCreation) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index e689978ee98..7993a6a6da8 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -30,6 +30,7 @@ import { createStructuredAgentSessionForWorktree } from './structured-agent-sess import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' import { deliverTerminalAgentLaunchPrompt } from './agent-launch-terminal-prompt' import { AgentLaunchSessionAlreadyExistsError } from '../../../../shared/agent-launch-session-already-exists' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' import { createStructuredAgentSessionId } from '../../../../shared/structured-agent-session-create' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' import { paneIdentity } from '../../runtime-terminal-pane-identity' @@ -64,10 +65,16 @@ export function agentLaunchSurfaceFactory( agent, options, sessionId: requested, - tabId + tabId, + withoutPrompt }) => { const sessionId = requested ?? createStructuredAgentSessionId(agent, randomUUID) const seeded = narrowStructuredLaunchSeedOptions(options) + // A client that creates chats without starting them starts this one on its first message. + const atRest = + withoutPrompt === true && + context.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) === + true const created = await createStructuredAgentSessionForWorktree({ runtime: context.runtime, ensureHost: async () => { @@ -91,6 +98,7 @@ export function agentLaunchSurfaceFactory( agent, ...(seeded ? { options: seeded } : {}), ...(tabId ? { tabId } : {}), + ...(atRest ? { atRest: true as const } : {}), activate: !callerPresentsSurface && (presentation === undefined || (presentation === 'focused' && movesHostWindow)) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 5a1fd705a46..ff00240018d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -48,9 +48,13 @@ vi.mock('../../../../shared/process-table-snapshot-reader', async (importOrigina function adapter(): StructuredAgentSessionAdapter { return { supportsCreate: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts index 8e3ca3fbf94..afa22af7a1a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts @@ -283,3 +283,21 @@ it.each(['options', 'firstMessage'] as const)( expect(rig.adapter.acquire).not.toHaveBeenCalled() } ) + +it('answers an old client whose start failed before spawning in plain words', async () => { + const raw = 'codex sessions pin CODEX_HOME, not CLAUDE_CONFIG_DIR' + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + rig.adapter.acquire.mockRejectedValueOnce(new AgentSessionPreSpawnError(new Error(raw))) + + const answer = await call('agentSession.create', params(), legacy) + + // Thrown as before, so the wire code an old client reads is unchanged; only the words are new. + expect(answer).toMatchObject({ + ok: false, + error: { + code: 'runtime_error', + message: "Codex couldn't start. Send your message to try again." + } + }) + expect(JSON.stringify(answer)).not.toContain(raw) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 80dee11ff2e..6fb7152ab65 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -260,10 +260,19 @@ export async function createStructuredAgentSessionForWorktree(args: { activate: boolean options?: Readonly> tabId?: string + /** Create without starting the agent; its first message starts it. */ + atRest?: true }): Promise> { + // An at-rest create's ledger row is keyed by its intent, as a client's create is. + const envelope = args.atRest + ? { + ...args.envelope, + payloadFingerprint: structuredAgentSessionCreateIntentFingerprint(args) + } + : args.envelope const prepared: PreparedStructuredAgentSessionCreate | StructuredCreateRefused = await resolveUncommittedStructuredCreate(() => - prepareStructuredAgentSessionCreateForWorktree(args) + prepareStructuredAgentSessionCreateForWorktree({ ...args, envelope }) ) if ('refusal' in prepared) { return { ok: false, refusal: prepared.refusal } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts deleted file mode 100644 index 25467e57248..00000000000 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import '../unused-default-rpc-methods.test-fixture' -// `agentSession.hold` / `release` are kept answering for clients that still send them, and do -// nothing else: a view never starts or keeps an agent. -// -// Run against the REAL subscription registry rather than a stub, because the claim includes that a -// connection closing runs nothing hold-related — a stubbed registry could not show that. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { AgentSessionRecordStore } from '../../agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { OrcaRuntimeService } from '../../orca-runtime' -import type { RpcResponse } from '../core' -import { RpcDispatcher } from '../dispatcher' -import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' -import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' -import { getDefaultPersistedState } from '../../../../shared/constants' -import { RuntimeClientSettingsController } from '../../runtime-client-settings' - -const CONNECTION = 'connection-1' -const CLIENT = { - clientId: 'device-1', - clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - connectionId: CONNECTION -} - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let runtime: OrcaRuntimeService -let dispatcher: RpcDispatcher -let closeSession: Mock> -let acquire: Mock -let requests = 0 -let structuredNativeChatEnabled = true - -async function call(method: string, params: unknown): Promise { - const replies: RpcResponse[] = [] - requests += 1 - await dispatcher.dispatchStreaming( - { id: `request-${requests}`, authToken: 'token', method, params }, - (raw) => replies.push(JSON.parse(raw) as RpcResponse), - CLIENT - ) - return replies[0] as RpcResponse -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-hold-wire-')) - resetHostTestOperationIds() - requests = 0 - structuredNativeChatEnabled = true - closeSession = vi.fn(async () => true) - acquire = vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire, - // A failed acquisition is proven gone, as the real adapters prove it; without this an - // acquire that throws leaves an unverifiable owner nothing may replace. - releaseAcquisition: vi.fn(async () => true), - closeSession, - dispatch: async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - setStructuredAgentSessionHost(host) - runtime = new OrcaRuntimeService() - // The structured surface is settings-gated for every caller, in-process included. - const clientSettings = new RuntimeClientSettingsController({ - getSettings: () => ({ - ...getDefaultPersistedState(root).settings, - experimentalNativeChat: structuredNativeChatEnabled - }), - updateSettings: () => undefined - }).get() - vi.spyOn(runtime, 'getClientSettings').mockImplementation(() => clientSettings) - dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ - ok: true - }) -}) - -afterEach(async () => { - setStructuredAgentSessionHost(null) - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('the hold surface, for clients that still call it', () => { - it('answers a hold without starting an agent or registering a cleanup', async () => { - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') - const acquiresBefore = acquire.mock.calls.length - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - - expect(acquire.mock.calls.length).toBe(acquiresBefore) - expect(registered).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('answers a release without stopping anything, and a connection close runs nothing', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { released: true } }) - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - - it('answers a hold and a release whatever the host structured-chat setting says', async () => { - structuredNativeChatEnabled = false - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { released: true } }) - expect(closeSession).not.toHaveBeenCalled() - }) - - it('answers a hold even when no agent could be started', async () => { - await host.close(SESSION, 'evict') - acquire.mockRejectedValue(new Error('provider unavailable')) - const acquiresBefore = acquire.mock.calls.length - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - expect(acquire.mock.calls.length).toBe(acquiresBefore) - }) -}) - -describe('a stream', () => { - it('reads a closed conversation without starting its agent', async () => { - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - const acquiresBefore = acquire.mock.calls.length - const frames: unknown[] = [] - - await dispatcher.dispatchStreaming( - { - id: 'request-subscribe', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - (raw) => frames.push(JSON.parse(raw)), - CLIENT - ) - - expect(frames).toContainEqual( - expect.objectContaining({ result: expect.objectContaining({ type: 'snapshot' }) }) - ) - expect(acquire.mock.calls.length).toBe(acquiresBefore) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('keeps nothing alive when its transport dies', async () => { - const transport = new AbortController() - await dispatcher.dispatchStreaming( - { - id: 'desktop-subscription', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - { - signal: transport.signal, - clientId: 'desktop-renderer', - clientKind: 'runtime', - clientCapabilities: CLIENT.clientCapabilities - } - ) - - transport.abort() - await new Promise((resolve) => setTimeout(resolve, 20)) - - // The idle sweep, not a departing reader, is what stops an agent. - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) -}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index bcae6db553f..17f4abd0538 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -3,15 +3,10 @@ import '../unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability' import type { AgentSessionRecord } from '../../../../shared/agent-session-record' import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { - PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' import { call, clearStructuredHostStub, @@ -168,71 +163,6 @@ describe('agentSession.modelCatalog before anything built the host', () => { }) }) -const PI_CLIENT = { - ...STRUCTURED_CLIENT, - clientCapabilities: [ - ...STRUCTURED_CLIENT.clientCapabilities, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY - ] -} - -describe("agentSession.modelCatalog with what the running agent's start said", () => { - const read = vi.fn(async (): Promise => ({ origin: 'unknown' })) - const startUnavailable = vi.fn((_sessionId: string): AgentSessionUnavailable | undefined => ({ - reason: 'notSignedIn' - })) - const record: AgentSessionRecord = { ...agentSessionRecordFixture(), provider: 'pi' } - const readFor = (agent: string) => - call('agentSession.modelCatalog', { agent, sessionId: record.sessionId }, PI_CLIENT) - - beforeEach(() => { - read.mockReset() - read.mockResolvedValue({ origin: 'unknown' }) - startUnavailable.mockClear() - setStructuredAgentSessionHost( - Object.assign(hostStub(), { - deps: { - modelCatalog: { read }, - store: { getRecord: () => record }, - adapter: { startUnavailable } - } - }) - ) - }) - - it("answers a Pi chat's catalog with its signed-out start", async () => { - const reply = await readFor('pi') - expect(startUnavailable).toHaveBeenCalledWith(record.sessionId) - expect(reply).toMatchObject({ - ok: true, - result: { origin: 'unknown', unavailable: { reason: 'notSignedIn' } } - }) - }) - - it('keeps the reason the catalog itself found', async () => { - read.mockResolvedValue({ origin: 'unknown', unavailable: { reason: 'cliMissing' } }) - expect(await readFor('pi')).toMatchObject({ - ok: true, - result: { unavailable: { reason: 'cliMissing' } } - }) - }) - - it("never lends one agent's start to another agent's read", async () => { - const reply = await readFor('codex') - expect(startUnavailable).not.toHaveBeenCalled() - expect(reply).toMatchObject({ ok: true, result: { origin: 'unknown' } }) - expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) - }) - - it('stays quiet while the start said nothing', async () => { - startUnavailable.mockReturnValueOnce(undefined) - const reply = await readFor('pi') - expect(reply).toMatchObject({ ok: true }) - expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) - }) -}) - describe('agentSession.modelCatalog for a chat on a model its CLI stopped listing', () => { const opus = { id: 'opus', label: 'Opus', isDefault: true, efforts: [] } const read = vi.fn(async (): Promise => ({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 3b7fe057ea7..2622fdeab35 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -94,20 +94,19 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ const own = record?.provider === params.agent ? record : null // A reopened chat's own model stays a row, so a client that replaces an unlisted one keeps // it. Not a new chat's read (it names a worktree): that answer seeds the next new chat's list. - const answer = - own && worktree === undefined && finishedListingIds(listing) && listing.origin !== 'unknown' - ? { - ...listing, - models: structuredAgentSessionOptionModels( - listing.models, - own.options?.model, - (row) => row - ) - } - : listing - // The chat's running agent can say what no listing does: a Pi that started with no model. - const started = own ? host.deps.adapter.startUnavailable?.(own.sessionId) : undefined - return started && !answer.unavailable ? { ...answer, unavailable: started } : answer + return own && + worktree === undefined && + finishedListingIds(listing) && + listing.origin !== 'unknown' + ? { + ...listing, + models: structuredAgentSessionOptionModels( + listing.models, + own.options?.model, + (row) => row + ) + } + : listing } }) ] diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts index 8e7bdf86c22..2110c2e6b5b 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts @@ -55,12 +55,16 @@ function providerAdapter(): StructuredAgentSessionAdapter { return { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async (input) => { if (acquireFails) { throw new Error('provider failed to start') } acquisitions++ return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4000 + acquisitions, diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index fc747c7d3ed..873c986bbe5 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -11,6 +11,9 @@ describe('ACP agents in the runtime registrations', () => { expect( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) ).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode', 'opencode2', 'omp', 'antigravity']) + expect(structuredAgentRuntimeRegistration('codex')?.definition.capabilities.contextUsage).toBe( + true + ) expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ handleTransport: 'acp', accountHomeVariable: 'GROK_HOME', diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 20a31d05197..940a310e53d 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -123,6 +123,7 @@ function createAcpAdapter( const sources = structuredAgentLaunchSources(context) const part = acpStructuredLaunchPart(spec, { readJournal, + ...deps.acpLaunchCommand, dataDirectory: deps.stateDirectory, logger: deps.logger }) @@ -133,18 +134,13 @@ function createAcpAdapter( readLaunch: composeStructuredLaunchRead(definition, sources, part), dataDirectory: deps.stateDirectory, resolveLaunchArgs: () => deps.resolveLaunchArgs(spec.agent), - connect: (launch, options) => createAcpAgentConnection(launch, options), + connect: deps.openAcpConnection ?? createAcpAgentConnection, onChildWorkEvidence: (sessionId, evidence, producer) => context.host()?.publishChildWorkEvidence(sessionId, evidence, producer), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), onDispatchSettledLate: followUps.onDispatchSettledLate, logger: deps.logger, - // Every exit, expected or not: the host ends that child's record. - onEvent: (event) => { - if (event.type === 'ended') { - context.deliverLifecycle(event) - } - } + onEvent: context.deliverLifecycle }) } } diff --git a/src/main/runtime/structured-agent-scripted-child.test-fixture.ts b/src/main/runtime/structured-agent-scripted-child.test-fixture.ts new file mode 100644 index 00000000000..4a13eac2323 --- /dev/null +++ b/src/main/runtime/structured-agent-scripted-child.test-fixture.ts @@ -0,0 +1,33 @@ +// What the registration matrix drives each agent's real adapter through: a scripted provider child +// behind the adapter's own transport seam, so the adapter, host, store and journal are the shipped +// ones and no process is ever spawned. Each agent's fixture implements this; the matrix fails for a +// registration with none. + +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' + +export type ScriptedAgentChild = { + /** Runtime overrides that route this agent's adapter to the script, launch resolution included: + * a fixture that lets any path reach a real binary is a bug. */ + readonly deps: Partial + /** Each spawn's handshake holds until released: the protocol session it opens (a thread, an ACP + * session, Pi's session file, Claude's initialize answer) is not answered before. True by + * default; while false, a spawn's handshake answers at once. */ + holdHandshakes: boolean + /** Answers the held handshake of the newest spawn. */ + releaseHandshake(): void + /** Fails the held handshake of the newest spawn with this provider error; its process exits. */ + failHandshake(message: string): void + /** False: a prompt opens a turn that runs until it is interrupted or its child ends, as a turn + * with background work does. True by default: each prompt's turn completes at once. */ + completeTurns: boolean + /** The text of every prompt any spawn received, in order. */ + prompts(): readonly string[] + /** How many children were spawned. */ + spawns(): number + /** How many spawns reopened the conversation the previous child left (resume / load). */ + resumes(): number + /** How many children ended because Orca closed them. */ + closes(): number +} + +export type ScriptedAgentChildFactory = () => ScriptedAgentChild diff --git a/src/main/runtime/structured-agent-session-adapter-start-failure.test.ts b/src/main/runtime/structured-agent-session-adapter-start-failure.test.ts new file mode 100644 index 00000000000..fd2a38dab5d --- /dev/null +++ b/src/main/runtime/structured-agent-session-adapter-start-failure.test.ts @@ -0,0 +1,128 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + hostTestAttachParams, + hostTestMessage, + HOST_TEST_SESSION as SESSION +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { piScriptedChild } from '../pi/pi-scripted-child.test-fixture' +import { codexScriptedChild } from '../codex/codex-scripted-child.test-fixture' +import { acpScriptedChild } from '../acp/acp-scripted-child.test-fixture' +import type { ScriptedAgentChild } from './structured-agent-scripted-child.test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let directory: string | undefined +let operations = 0 +const operationId = () => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` +const caller = { callerKey: 'adapter-start-failure-test' } + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (directory) { + await rm(directory, { recursive: true, force: true }) + directory = undefined + } +}) + +async function failedStart(agent: 'pi' | 'codex' | 'grok', script: ScriptedAgentChild) { + directory = await mkdtemp(join(tmpdir(), 'orca-adapter-start-failure-')) + const root = directory + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({ PATH: '' }), + ...script.deps + }) + const params = hostTestAttachParams( + null, + agent === 'codex' + ? { providerHandle: undefined } + : { + provider: agent, + agent, + providerHandle: undefined, + accountHome: + agent === 'pi' + ? { variable: 'PI_CODING_AGENT_DIR', path: root } + : { variable: 'GROK_HOME', path: root }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + } + } + ) + params.envelope.clientOperationId = operationId() + const attached = await host.attach(caller, params) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + const body = hostTestMessage('held during start') + const sent = await host.send(caller, { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: attached.value.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + await vi.waitFor(() => expect(script.spawns()).toBe(1)) + script.failHandshake?.('provider config is invalid: bad key') + const submission = async () => { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === sent.value.clientMessageId + ) + } + await vi.waitFor(async () => expect((await submission())?.dispatchState).toBe('rejected'), { + timeout: 5_000 + }) + const settled = await submission() + const snapshot = await host.journalSnapshot(SESSION) + const failureKind = agent === 'codex' ? 'startFailed' : 'providerStartFailed' + expect(readWholeAgentSessionFailureFact(settled?.rejection)).toMatchObject({ + kind: failureKind, + detail: { text: 'provider config is invalid: bad key' } + }) + expect( + snapshot.items.filter( + (item) => + item.body.kind === 'status' && + readWholeAgentSessionFailureFact(item.body.failure)?.kind === failureKind + ) + ).toHaveLength(1) + expect(script.prompts()).toEqual([]) + expect(script.spawns()).toBe(1) +} + +it('Pi records one failure without dispatch after a failed start', async () => { + await failedStart('pi', piScriptedChild()) +}) +it('Codex records one failure without dispatch after a failed start', async () => { + await failedStart('codex', codexScriptedChild()) +}) +it('ACP records one failure without dispatch after a failed start', async () => { + await failedStart('grok', acpScriptedChild('grok')()) +}) diff --git a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts index 82aedb1cceb..b89664814f5 100644 --- a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts +++ b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts @@ -133,6 +133,7 @@ async function stop(turnId?: string): Promise { if (!stopped.ok) { throw new Error(JSON.stringify(stopped.refusal)) } + await host.collaboratorsForTests().serialize(SESSION, async () => {}) } /** `/compact` as the chat surface runs it; refused while the chat still owes work. */ @@ -249,7 +250,10 @@ beforeEach(async () => { resolveEnvironment: async () => ({ PATH: process.env.PATH }), ...scriptedCodexTransport(openConnection) }) - const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + const attachParams = hostTestAttachParams(null, { + providerHandle: undefined, + options: { model: MODEL.model } + }) attachParams.envelope.clientOperationId = operationId() const attached = await host.attach(CALLER, attachParams) if (!attached.ok) { @@ -361,9 +365,10 @@ describe("a message queued behind a send whose turn hasn't opened", () => { expect(openWaits.turnIds).toEqual([]) turns.start() + expect((await submission(second))?.handedOverAt).toBeUndefined() + turns.echo(first) await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) await vi.waitFor(() => expect(steers).toBe(1)) - turns.echo(first) says('working on the first', 'reply-1') says('more on both', 'reply-2') @@ -397,8 +402,8 @@ describe("a message queued behind a send whose turn hasn't opened", () => { expect((await submission(b))?.handedOverAt).toBeUndefined() turns.start() - await vi.waitFor(() => expect(steers).toBe(2)) turns.echo(zero) + await vi.waitFor(() => expect(steers).toBe(2)) says('working on zero', 'reply-0') expect(await rowScope(a)).toMatchObject({ kind: 'turn' }) @@ -528,17 +533,19 @@ describe('a send whose turn Codex answered and never opened', () => { expect(verdictOf((await settled()).submissions, first)).toBe('pending') expect((await submission(second))?.handedOverAt).toBeUndefined() turns.start() - await vi.waitFor(() => expect(steers).toBe(1)) turns.echo(first) + await vi.waitFor(() => expect(steers).toBe(1)) await vi.waitFor(async () => expect(verdictOf((await settled()).submissions, first)).toBe('accepted') ) }) - // Steered in the moment the turn opens, it can be echoed before the send that opened the turn. - it('keeps the turn, and its live status, on the send that opened it when the steer echoes first', async () => { + // The opener's echo identifies the choices a matching follow-up may steer into. + it('keeps the turn and its live status on the opener when a later steer echoes', async () => { const { first, second } = await heldBehindAnAnsweredSend() turns.start() + expect((await submission(second))?.handedOverAt).toBeUndefined() + turns.echo(first) await vi.waitFor(() => expect(steers).toBe(1)) turns.echo(second) @@ -546,7 +553,7 @@ describe('a send whose turn Codex answered and never opened', () => { expect(verdictOf((await settled()).submissions, second)).toBe('accepted') ) - expect(verdictOf((await settled()).submissions, first)).toBe('pending') + expect(verdictOf((await settled()).submissions, first)).toBe('accepted') const rows = await drawn() expect(rows.map((row) => row.text)).toEqual(['look around', 'and check the tests']) expect(new Set(rows.map((row) => row.turn))).toEqual( diff --git a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts new file mode 100644 index 00000000000..f31fdad35f4 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -0,0 +1,187 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { codexScriptedChild } from '../codex/codex-scripted-child.test-fixture' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { ScriptedAgentChild } from './structured-agent-scripted-child.test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let root: string | undefined +const caller = { callerKey: 'scripted-codex-test' } +let operation = 0 + +function envelope(method: string, fence: number, fields: Record) { + return { + sessionId: HOST_TEST_SESSION, + clientOperationId: `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: HOST_TEST_SESSION, + fields + }) + } +} +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +async function attachedHost(child: ScriptedAgentChild) { + root = await mkdtemp(join(tmpdir(), 'orca-scripted-codex-')) + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root ?? '', + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + ...child.deps + }) + const attach = hostTestAttachParams(null, { providerHandle: undefined }) + attach.envelope.clientOperationId = `${Date.now()}-${'b'.repeat(32)}` + const attached = await host.attach(caller, attach) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + return { host, fence: attached.value.fence } +} + +it('attaches at spawn and holds sends until the scripted Codex handshake answers', async () => { + const child = codexScriptedChild() + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('held until Codex is ready') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + expect(sent.ok).toBe(true) + expect(child.spawns()).toBe(1) + expect(host.deps.store.getRecord(HOST_TEST_SESSION)?.lease.ownerProcess).toMatchObject({ + pid: expect.any(Number), + spawnToken: expect.any(String) + }) + expect(child.prompts()).toEqual([]) + child.releaseHandshake() + await vi.waitFor(() => expect(child.prompts()).toEqual(['held until Codex is ready'])) +}) + +it('can Stop the scripted child during initialization and settle a held send as withdrawn', async () => { + const child = codexScriptedChild() + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('never handed over') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + expect( + (await host.cancel(caller, { envelope: envelope('agentSession.cancel', fence, {}) })).ok + ).toBe(true) + await host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) + expect(child.closes()).toBe(1) + expect(child.prompts()).toEqual([]) + const { submissions } = await host.journalSnapshot(HOST_TEST_SESSION) + expect( + submissions.find((entry) => entry.clientMessageId === sent.value.clientMessageId)?.dispatchState + ).toBe('rejected') +}) + +it('hands Codex a model and effort picked while it started before the first message', async () => { + const child = codexScriptedChild() + const { host, fence } = await attachedHost(child) + const picks = ( + [ + ['model', 'scripted-alt-model'], + ['effort', 'high'] + ] as const + ).map(([key, value]) => { + const fields = { key, value } + return host.setOption(caller, { + envelope: envelope('agentSession.setOption', fence, fields), + ...fields + }) + }) + const body = hostTestMessage('first message') + const sent = host.send(caller, { envelope: envelope('agentSession.send', fence, { body }), body }) + child.releaseHandshake() + expect(await Promise.all(picks)).toEqual([ + expect.objectContaining({ ok: true }), + expect.objectContaining({ ok: true }) + ]) + expect((await sent).ok).toBe(true) + + await vi.waitFor(() => expect(child.prompts()).toEqual(['first message'])) + const turn = child.requests().find(({ method }) => method === 'turn/start') + expect(turn?.params).toMatchObject({ model: 'scripted-alt-model', effort: 'high' }) + expect(host.deps.store.getRecord(HOST_TEST_SESSION)?.options).toMatchObject({ + model: 'scripted-alt-model', + effort: 'high' + }) +}) + +it('rejects a held send once when Codex initialization refuses and closes the child once', async () => { + const child = codexScriptedChild() + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('never delivered') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + await vi.waitFor(() => + expect(child.requests().some((request) => request.method === 'initialize')).toBe(true) + ) + child.failHandshake('Please sign in to Codex') + await vi.waitFor(async () => { + const { submissions } = await host.journalSnapshot(HOST_TEST_SESSION) + const rejected = submissions.filter( + (entry) => entry.clientMessageId === sent.value.clientMessageId + ) + expect(rejected).toHaveLength(1) + expect(rejected[0]?.dispatchState).toBe('rejected') + }) + expect(child.closes()).toBe(1) + expect(child.spawns()).toBe(1) + expect(child.prompts()).toEqual([]) +}) + +it('stops while thread/start is held and never hands over the accepted send', async () => { + const thread = Promise.withResolvers() + const child = codexScriptedChild({ 'thread/start': () => thread.promise }) + child.holdHandshakes = false + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('never delivered') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + expect(sent.ok).toBe(true) + await vi.waitFor(() => + expect(child.requests().some((request) => request.method === 'thread/start')).toBe(true) + ) + expect( + (await host.cancel(caller, { envelope: envelope('agentSession.cancel', fence, {}) })).ok + ).toBe(true) + thread.resolve({ thread: { id: 'scripted-codex-thread' } }) + await host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) + expect(child.closes()).toBe(1) + expect(child.prompts()).toEqual([]) +}) diff --git a/src/main/runtime/structured-agent-session-codex-start-deadline.test.ts b/src/main/runtime/structured-agent-session-codex-start-deadline.test.ts new file mode 100644 index 00000000000..03b58ea6af2 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-start-deadline.test.ts @@ -0,0 +1,121 @@ +// Each step of a Codex start has its own bound: an `initialize` that never answers fails the start +// at 15 s with the startup-failure words, and the host ends the child; one that answers in time +// starts as usual. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { codexScriptedChild } from '../codex/codex-scripted-child.test-fixture' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { recordAgentSessionStartup } from '../observability/agent-session-instrumentation' +import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' +import type { ScriptedAgentChild } from './structured-agent-scripted-child.test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +vi.mock('../observability/agent-session-instrumentation', async (importOriginal) => ({ + ...(await importOriginal>()), + recordAgentSessionStartup: vi.fn() +})) + +let root: string | undefined +const caller = { callerKey: 'codex-start-deadline-test' } +let operation = 0 + +beforeEach(() => { + // Real time still passes, so file and database work settles; the test jumps the clock. + vi.useFakeTimers({ + shouldAdvanceTime: true, + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] + }) + vi.mocked(recordAgentSessionStartup).mockClear() +}) + +afterEach(async () => { + vi.useRealTimers() + await stopStructuredAgentSessionRuntime() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +function envelope(method: string, fence: number, fields: Record) { + return { + sessionId: HOST_TEST_SESSION, + clientOperationId: `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: HOST_TEST_SESSION, + fields + }) + } +} + +async function chatHoldingAMessage(child: ScriptedAgentChild) { + root = await mkdtemp(join(tmpdir(), 'orca-codex-start-deadline-')) + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root ?? '', + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + ...child.deps + }) + const attach = hostTestAttachParams(null, { providerHandle: undefined }) + attach.envelope.clientOperationId = `${Date.now()}-${'d'.repeat(32)}` + const attached = await host.attach(caller, attach) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + const body = hostTestMessage('held through a slow Codex start') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', attached.value.fence, { body }), + body + }) + expect(sent.ok).toBe(true) + return host +} + +function startupOutcomes(): string[] { + return vi.mocked(recordAgentSessionStartup).mock.calls.map(([startup]) => startup.outcome) +} + +it('delivers the held message to a Codex that answers initialize within its bound', async () => { + const child = codexScriptedChild() + await chatHoldingAMessage(child) + await vi.advanceTimersByTimeAsync(14_000) + expect(child.closes()).toBe(0) + + child.releaseHandshake() + await vi.waitFor(() => expect(child.prompts()).toEqual(['held through a slow Codex start'])) + expect(child.spawns()).toBe(1) + expect(startupOutcomes()).toEqual(['ready']) +}) + +it('fails the held message with the startup-failure words when initialize passes its bound', async () => { + const child = codexScriptedChild() + const host = await chatHoldingAMessage(child) + await vi.advanceTimersByTimeAsync(15_000) + + await vi.waitFor(async () => { + await host.flushStreamedEvents(HOST_TEST_SESSION) + const [held] = (await host.journalSnapshot(HOST_TEST_SESSION)).submissions + expect(held?.dispatchState).toBe('rejected') + expect(readWholeAgentSessionFailureFact(held?.rejection)).toMatchObject({ kind: 'startFailed' }) + }) + await vi.waitFor(() => expect(child.closes()).toBe(1)) + expect(child.prompts()).toEqual([]) + expect(child.spawns()).toBe(1) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 5dc03b0074f..13423e9fa07 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -283,7 +283,10 @@ beforeEach(async () => { resolveEnvironment: async () => ({ PATH: process.env.PATH }), ...scriptedCodexTransport(openConnection) }) - const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + const attachParams = hostTestAttachParams(null, { + providerHandle: undefined, + options: { model: MODEL.model } + }) attachParams.envelope.clientOperationId = operationId() const attached = await host.attach(CALLER, attachParams) if (!attached.ok) { @@ -299,6 +302,14 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +async function runningTurn(): Promise { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + return opening +} + describe('a Codex send its turn ended without taking it', () => { it('is withdrawn by a Stop before any echo, and nothing reads as working after', async () => { const sent = await send('look around') @@ -324,10 +335,7 @@ describe('a Codex send its turn ended without taking it', () => { }) it('withdraws a follow-up Codex steered into the turn a Stop ends, with no working latch', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + const opening = await runningTurn() const followUp = await send('and check the tests') // Steered into the running turn by name, which fires no second turn/started. await vi.waitFor(() => expect(steers).toBe(1)) @@ -363,10 +371,8 @@ describe('the turn a withdrawn Codex send was answered into', () => { } } - it("is that turn's record, started by the send that opened it and steered by a later one", async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() + it('keeps the turn identity of a steering send after the opening message is identified', async () => { + const opening = await runningTurn() const steered = await send('and check the tests') await vi.waitFor(() => expect(steers).toBe(1)) @@ -377,9 +383,7 @@ describe('the turn a withdrawn Codex send was answered into', () => { const { turnRecords } = await answeredInto(opening) expect(turnRecords).toHaveLength(1) - const started = { turnItemId: turnRecords[0], via: 'start' } const steeredIn = { turnItemId: turnRecords[0], via: 'steer' } - expect(await answeredInto(opening)).toEqual({ turnRecords, named: started, onPage: started }) expect(await answeredInto(steered)).toEqual({ turnRecords, named: steeredIn, @@ -388,10 +392,7 @@ describe('the turn a withdrawn Codex send was answered into', () => { }) it('is not named on a send the turn took', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + const opening = await runningTurn() await stop('turn-1') await vi.waitFor(async () => @@ -442,10 +443,7 @@ describe('a queued card sent now into the turn a Stop ends', () => { } it('comes back as a paused waiting card, and nothing sends it again', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() const { messageId: cardId } = await queueThenSendNow('and check the tests') // Steered into the running turn, with no echo yet. await vi.waitFor(() => expect(steers).toBe(1)) @@ -489,10 +487,7 @@ describe('a Codex before 0.148, which names a steered start falsely', () => { () => (method, params) => handlers?.onNotification?.(method, params), { legacyStartAnswers: true } ) - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() const followUp = await send('and check the tests') await vi.waitFor(() => expect(steers + answers).toBe(2)) @@ -510,13 +505,6 @@ describe('a Codex before 0.148, which names a steered start falsely', () => { }) describe("a queued card's Send-now while a Codex turn runs", () => { - async function runningTurn(): Promise { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) - } - it('goes into that turn as turn/steer, and the turn settles it when Codex echoes it', async () => { await runningTurn() @@ -558,12 +546,7 @@ describe("a queued card's Send-now while a Codex turn runs", () => { }) describe('a second send made after Codex answered the first, before it opened that turn', () => { - it('joins that turn once it opens, on a Codex that names a steered start falsely, so a Stop withdraws both', async () => { - turns = codexTurnLifecycleFake( - THREAD, - () => (method, params) => handlers?.onNotification?.(method, params), - { legacyStartAnswers: true } - ) + it('waits for the opener before steering the later send, which Stop then withdraws', async () => { const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const followUp = await send('and check the tests') @@ -573,6 +556,14 @@ describe('a second send made after Codex answered the first, before it opened th await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) expect(openWaits.turnIds).toEqual([]) turns.start() + await host.flushStreamedEvents(SESSION) + expect(host.collaboratorsForTests().sessions.get(SESSION)?.journal.newestTurn()?.state).toBe( + 'running' + ) + await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) + expect(steers).toBe(0) + expect(verdictOf((await settled()).submissions, followUp)).toBe('pending') + turns.echo(opening) await vi.waitFor(() => expect(steers).toBe(1)) expect(answers).toBe(1) @@ -582,7 +573,7 @@ describe('a second send made after Codex answered the first, before it opened th expect(verdictOf((await settled()).submissions, followUp)).not.toBe('pending') ) const after = await settled() - expect(verdictOf(after.submissions, opening)).toBe('withdrawn') + expect(verdictOf(after.submissions, opening)).toBe('accepted') expect(verdictOf(after.submissions, followUp)).toBe('withdrawn') expect(after.owesWork).toBe(false) }) @@ -862,10 +853,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { // Handed over once the stopped turn read ended, it started its own turn, however late that // turn's own end then arrives; a second Stop finds its turn never opened. it('withdraws a send made after a Stop that started its own turn, which never opened', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() interruptEndHeld = true await stop() interruptEndHeld = false @@ -882,10 +870,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { // Codex drains a steer into the running turn, so it may hold one it never echoed. it('leaves a send steered into the open turn in doubt when the child end follows', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() const steered = await send('and check the tests') await vi.waitFor(() => expect(steers).toBe(1)) interruptsFail = true diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts deleted file mode 100644 index d155df5d48d..00000000000 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -// Failures the desktop host used to drop: it installed the runtime with no error callback, so -// each of these reached nobody. Every one now lands in the required logger under its own scope. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import type { StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { StructuredAgentSessionHostRuntimeState } from '../native-chat/agent-session-wire/structured-agent-session-host-runtime-state' -import { - StructuredAgentSessionDeliveryLoop, - type StructuredAgentSessionDeliveryLoopDeps -} from '../native-chat/agent-session-wire/structured-agent-session-delivery-loop' -import { - StructuredAgentSessionIdleSweep, - type StructuredAgentSessionIdleSweepDeps -} from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' -import { - StructuredAgentSessionQueuedMessageDrain, - type QueuedMessageDrainDeps -} from '../native-chat/agent-session-wire/structured-agent-session-queued-messages' -import { - createStructuredAgentSessionLogger, - neverThrowingStructuredAgentSessionLogger -} from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { StructuredAgentSessionDeliveryOrder } from '../native-chat/agent-session-wire/structured-agent-session-delivery-order' -import { _resetTracerForTests, setActiveSink } from '../observability/tracer' -import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' -import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' -import { - STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED, - ensureStructuredAgentSessionHost, - stopStructuredAgentSessionRuntime -} from './structured-agent-session-runtime' - -const SESSION = 'session-1' - -function rejectingHost(error: Error) { - return { - settleLateDispatch: vi.fn(async () => { - throw error - }), - releaseUnansweredDispatches: vi.fn(async () => { - throw error - }) - } -} - -describe('failures the desktop host used to drop', () => { - it('logs a late dispatch settlement that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('settlement write failed') - const followUps = createStructuredAgentSessionDispatchFollowUps({ - host: () => rejectingHost(error), - logger: log.logger - }) - - followUps.onDispatchSettledLate({ - sessionId: SESSION, - clientMessageId: 'client-1', - state: 'unknown', - reason: 'provider went idle' - }) - - await vi.waitFor(() => - expect(log.entries).toEqual([ - { - level: 'warn', - message: 'settling a dispatch the provider answered late failed', - fields: { scope: 'late-settlement', sessionId: SESSION, error } - } - ]) - ) - }) - - it('logs releasing the sends an idle provider never answered when that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('release write failed') - const followUps = createStructuredAgentSessionDispatchFollowUps({ - host: () => rejectingHost(error), - logger: log.logger - }) - - followUps.releaseUnansweredDispatches({ sessionId: SESSION }) - - await vi.waitFor(() => - expect(log.entries).toEqual([ - expect.objectContaining({ - fields: { scope: 'unanswered-dispatch', sessionId: SESSION, error } - }) - ]) - ) - }) - - it('logs a provider start or exit the host could not take', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('host refused the event') - const lifecycle = createStructuredAgentSessionLifecycleDelivery({ - handle: async () => { - throw error - }, - logger: log.logger, - drainObservedExits: async () => {} - }) - - lifecycle.deliver({ - type: 'started', - sessionId: SESSION, - fence: 1, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'gpt-5' } - }) - lifecycle.deliver({ - type: 'ended', - sessionId: SESSION, - reason: 'exited', - cause: 'unexpected-exit', - fence: 1, - acquisitionGeneration: 'generation-1' - }) - await lifecycle.drain() - - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'lifecycle-started', sessionId: SESSION, error }, - { scope: 'lifecycle-exit', sessionId: SESSION, error } - ]) - }) - - it('logs provider events the chat journal would not take', async () => { - const log = recordingStructuredAgentSessionLogger() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: minting and binding a sink reads no other host dependency. - const deps = { logger: log.logger } as unknown as StructuredAgentSessionHostDeps - const runtime = new StructuredAgentSessionHostRuntimeState(deps, new Map()) - const sink = runtime.eventSinkFor(SESSION) - const error = new Error('journal append failed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sink calls only appendItem before it fails. - const journal = { - appendItem: async () => { - throw error - } - } as unknown as AgentSessionJournal - sink.bind({ journal, fence: 1, publish: () => {} }) - - sink.sink.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await expect(sink.drained()).resolves.toMatchObject({ ok: false }) - - expect(log.entries).toEqual([ - expect.objectContaining({ - level: 'error', - fields: { scope: 'journal-event-sink', sessionId: SESSION, error } - }) - ]) - }) -}) - -describe('host collaborators log with their own scope', () => { - const error = new Error('serialize refused') - const rejectingSerialize = async (): Promise => { - throw error - } - - it('the delivery loop logs a failed delivery, and a failed recording of it', async () => { - const log = recordingStructuredAgentSessionLogger() - const loop = new StructuredAgentSessionDeliveryLoop( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a step that rejects at its first serialize reads only these members. - { - serialize: rejectingSerialize, - sessions: new Map(), - deliveryOrder: new StructuredAgentSessionDeliveryOrder(), - trackStart: (start: Promise) => start, - logger: log.logger - } as unknown as StructuredAgentSessionDeliveryLoopDeps - ) - - loop.wake(SESSION) - - await vi.waitFor(() => - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'delivery-loop', sessionId: SESSION, error }, - { scope: 'delivery-loop-fail', sessionId: SESSION, error } - ]) - ) - }) - - it('the idle sweep logs a step that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const sweep = new StructuredAgentSessionIdleSweep( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a tick whose serialize rejects reads only these members. - { - sessions: new Map([[SESSION, {}]]), - isDisposed: () => false, - serialize: rejectingSerialize, - logger: log.logger - } as unknown as StructuredAgentSessionIdleSweepDeps - ) - - await sweep.tick() - - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'idle-sweep', sessionId: SESSION, error } - ]) - }) - - it('the queued-message drain logs a step that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const journal = { queuedMessages: { settlementOwed: () => true } } - const drain = new StructuredAgentSessionQueuedMessageDrain( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members. - { - sessions: new Map([[SESSION, { journal }]]), - serialize: rejectingSerialize, - logger: log.logger - } as unknown as QueuedMessageDrainDeps - ) - - drain.schedule(SESSION) - - await vi.waitFor(() => - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'queued-drain', sessionId: SESSION, error } - ]) - ) - }) -}) - -describe('a logger that throws', () => { - it('never throws out of the report, and still prints what failed', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const loggerError = new Error('logger broke') - const logger = neverThrowingStructuredAgentSessionLogger({ - warn: () => { - throw loggerError - }, - error: () => { - throw loggerError - } - }) - const error = new Error('what failed') - - expect(() => logger.warn('a step failed', { scope: 'step', error })).not.toThrow() - expect(() => logger.error('a step failed', { scope: 'step', error })).not.toThrow() - expect(warn).toHaveBeenCalledWith('[agent-session] a step failed', { - scope: 'step', - error, - loggerError - }) - warn.mockRestore() - }) -}) - -describe('the production logger', () => { - afterEach(() => { - _resetTracerForTests() - vi.restoreAllMocks() - }) - - it('writes each entry to the trace file as a failed span named for its scope', () => { - const push = vi.fn() - setActiveSink({ push, flush: () => {}, close: () => {} }) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - createStructuredAgentSessionLogger().warn('settling a late dispatch failed', { - scope: 'late-settlement', - sessionId: SESSION, - error: new Error('disk full') - }) - - expect(push).toHaveBeenCalledOnce() - expect(push.mock.calls[0]?.[0]).toMatchObject({ - type: 'effect-span', - name: 'agentSession.late-settlement', - attributes: { - level: 'warn', - message: 'settling a late dispatch failed', - sessionId: SESSION - }, - exit: { _tag: 'Failure', cause: expect.stringContaining('Error: disk full') } - }) - }) -}) - -describe('installing the runtime', () => { - let stateDirectory: string | null = null - - afterEach(async () => { - await stopStructuredAgentSessionRuntime() - if (stateDirectory) { - await rm(stateDirectory, { recursive: true, force: true }) - stateDirectory = null - } - }) - - it('refuses without a logger rather than dropping every failure', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) - stateDirectory = root - - await expect( - ensureStructuredAgentSessionHost( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately omits the logger to verify installation rejects it. - { - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }) - } as unknown as Parameters[0] - ) - ).rejects.toThrow(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) - }) - - it('hands the host a logger whose throw cannot reach the operation it reports', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) - stateDirectory = root - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const host = await ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveEnvironment: async () => ({}), - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }), - logger: { - warn: () => { - throw new Error('logger broke') - }, - error: () => { - throw new Error('logger broke') - } - } - }) - - expect(() => host.deps.logger.warn('a step failed', { scope: 'step' })).not.toThrow() - }) -}) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index c55bdb2f932..f33e9e2b6ba 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -122,16 +122,12 @@ describe('a structured codex session over agentSession.*', () => { delta: 'Buffered while the journal opens.' }) - let stopped = false - const stopping = stopStructuredAgentSessionRuntime().then(() => { - stopped = true - }) - await new Promise((resolve) => setImmediate(resolve)) - const waitedForJournalBind = !stopped openGate.resolve() await creating - await stopping - expect(waitedForJournalBind).toBe(true) + await vi.waitFor(() => + expect(harness.codex.live().calls.some((entry) => entry.method === 'thread/start')).toBe(true) + ) + await stopStructuredAgentSessionRuntime() const identity = { sessionId: SESSION, diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 6f6b38378cd..01c70a3f213 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -17,10 +17,8 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../ipc/desktop-renderer-runtime-capabilities' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../shared/agent-session-create-capabilities' import type { AgentJournalRenderItem, AgentJournalSubmission @@ -61,16 +59,15 @@ const JOURNAL_IDENTITY = { agent: 'codex' as const, providerHandle: codexProviderHandle(THREAD) } -// The capability set the desktop renderer advertises. Without the pending-send +// An eager-start client with current options capabilities. Without the pending-send // one the host holds the reply until the send settles, which is a shim for // clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, - clientCapabilities: [ - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ) } // ─── the fake `codex app-server` ──────────────────────────────────────────── @@ -96,7 +93,7 @@ type FakeConnection = Omit & { function fakeCodex(): CodexScript { const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { const connection: FakeConnection = { launch, handlers, @@ -147,7 +144,7 @@ function fakeCodex(): CodexScript { } connections.push(connection) return connection - }) as typeof openCodexAppServerConnection + } const live = (): FakeConnection => { const connection = connections.at(-1) if (!connection) { @@ -318,6 +315,20 @@ async function historyPage( return (response as { result: AgentSessionHistoryResult }).result } +async function readKnownOptions(): Promise { + return vi.waitFor(async () => { + const result = await call('agentSession.options', { sessionId: SESSION }) + expect(result).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + return result + }) +} + beforeEach(async () => { operations = 0 root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) @@ -337,7 +348,7 @@ beforeEach(async () => { providerHandle: _providerHandle, ...resolved } = attachParams(null) - return resolved + return { ...resolved, options: { model: 'gpt-live' } } }, publishStructuredAgentSessionTab: () => {}, ensureStructuredAgentSessionHost: () => @@ -443,13 +454,7 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items.map(textOf)).toContain('legacy question') - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } - }) + await readKnownOptions() }) it('dispatches and streams a plain first send from a fresh session', async () => { @@ -523,24 +528,19 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items).toEqual([]) - expect(codex.live().calls[0]).toMatchObject({ - method: 'thread/start', - params: { cwd: `/repos/${WORKSPACE}` } - }) + // Create answers at spawn; the thread opens once Codex has initialized. + await vi.waitFor(() => + expect(codex.live().calls.find((call) => call.method === 'thread/start')).toMatchObject({ + params: { cwd: `/repos/${WORKSPACE}` } + }) + ) const fence = created.fence const stream = await subscribe('sub-1') expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) // ── options ───────────────────────────────────────────────────────────── - const options = await call('agentSession.options', { sessionId: SESSION }) - expect(options).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } - }) + await readKnownOptions() await ok('agentSession.setOption', { envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence), key: 'model', @@ -551,10 +551,12 @@ describe('a structured codex session over agentSession.*', () => { key: 'effort', value: 'high' }) - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { current: { model: 'gpt-live', effort: 'high' } } - }) + await vi.waitFor(async () => + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-live', effort: 'high' } } + }) + ) expect(itemsOf(stream)).toEqual([]) // ── send ──────────────────────────────────────────────────────────────── @@ -696,12 +698,9 @@ describe('a structured codex session over agentSession.*', () => { expect(resumed.fence).toBe(fence + 1) expect(reaped.closed).toBe(true) expect(codex.live().resumedThreadId).toBe(THREAD) - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + expect(await readKnownOptions()).toMatchObject({ ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live', effort: 'high' } - } + result: { current: { effort: 'high' } } }) // The journal belongs to the session, not to the process that just died. expect(resumed.page.items.map(textOf)).toContain('Two files.') @@ -726,7 +725,7 @@ describe('a structured codex session over agentSession.*', () => { // The turn's lifecycle row outlives the turn: it is revised, never tombstoned. expect([...older.page.items, ...tail.page.items].map((item) => item.body?.kind)).toEqual([ 'message', - 'status', + 'turn', 'message', 'tool-call', 'approval', diff --git a/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts b/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts new file mode 100644 index 00000000000..0d5faf66d5f --- /dev/null +++ b/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts @@ -0,0 +1,113 @@ +// A signed-out Pi fails construction once; a later send after sign-in opens a new child. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + hostTestMessage, + HOST_TEST_SESSION as SESSION +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createTestParams } from '../native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { piScriptedChild } from '../pi/pi-scripted-child.test-fixture' +import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let directory: string | undefined +let operations = 0 +const operationId = () => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` +const caller = { callerKey: 'pi-signed-out-start-test' } + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (directory) { + await rm(directory, { recursive: true, force: true }) + directory = undefined + } +}) + +it('settles the held message once, closes Pi, and starts afresh for a later send after sign-in', async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-pi-signed-out-')) + const root = directory + const script = piScriptedChild({ signedOut: true }) + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({}), + ...script.deps + }) + const firstMessage = { + clientMessageId: operationId(), + body: hostTestMessage('hello, signed-out Pi') + } + // As a new chat sends its first message: the start is made for it. + const params = createTestParams(firstMessage, { + provider: 'pi', + agent: 'pi', + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: root }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + } + }) + params.envelope.clientOperationId = operationId() + const created = await host.create(caller, params, { firstMessage }) + if (!created.ok) { + throw new Error(JSON.stringify(created.refusal)) + } + // Pi answers its start after the host has published it. + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) + script.releaseHandshake() + const submission = async () => { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === firstMessage.clientMessageId + ) + } + + await vi.waitFor(async () => expect((await submission())?.dispatchState).toBe('rejected'), { + timeout: 5_000 + }) + expect(readWholeAgentSessionFailureFact((await submission())?.rejection)?.kind).toBe( + 'notSignedIn' + ) + await vi.waitFor(() => expect(script.closes()).toBe(1)) + expect(script.spawns()).toBe(1) + expect(script.prompts()).toEqual([]) + script.signIn() + const body = hostTestMessage('hello after sign-in') + const fields = { body } + const sent = await host.send(caller, { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: + peekOpenedAgentSessionRecordStore()?.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields + }) + }, + body + }) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(script.spawns()).toBe(2)) + script.releaseHandshake() + await vi.waitFor(() => expect(script.prompts()).toEqual(['hello after sign-in'])) +}) diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts new file mode 100644 index 00000000000..bf399d16ab4 --- /dev/null +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -0,0 +1,177 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + hostTestAttachParams, + hostTestMessage, + HOST_TEST_SESSION +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { piScriptedChild } from '../pi/pi-scripted-child.test-fixture' +import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let directory: string | undefined +let operations = 0 +const operationId = () => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (directory) { + await rm(directory, { recursive: true, force: true }) + directory = undefined + } +}) + +async function setup() { + directory = await mkdtemp(join(tmpdir(), 'orca-pi-startup-')) + const root = directory + const script = piScriptedChild() + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({}), + ...script.deps + }) + const caller = { callerKey: 'pi-startup-test' } + const params = hostTestAttachParams(null, { + provider: 'pi', + agent: 'pi', + providerHandle: undefined, + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: root }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + } + }) + params.envelope.clientOperationId = operationId() + const attached = await host.attach(caller, params) + expect(attached.ok).toBe(true) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + return { script, host, caller, fence: attached.value.fence } +} + +function envelope(method: string, fields: Record) { + return { + sessionId: HOST_TEST_SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: + peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: HOST_TEST_SESSION, + fields + }) + } +} + +it('attaches Pi at spawn and holds the opening send until the provider answers its handshake', async () => { + const { script, host, caller, fence } = await setup() + expect(script.spawns()).toBe(1) + const body = hostTestMessage('hello while Pi is starting') + const sent = await host.send(caller, { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + }, + body + }) + expect(sent.ok).toBe(true) + expect(script.prompts()).toEqual([]) + script.releaseHandshake() + await vi.waitFor(() => expect(script.prompts()).toEqual(['hello while Pi is starting'])) + await vi.waitFor(() => + expect( + peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options + ).toBeUndefined() + ) + await stopStructuredAgentSessionRuntime() + expect(script.closes()).toBe(1) +}) + +it('writes startup model and effort picks before a message accepted after them', async () => { + const { script, host, caller } = await setup() + const picked = ( + [ + ['model', 'scripted/model-2'], + ['effort', 'low'] + ] as const + ).map(([key, value]) => + host.setOption(caller, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + ) + const body = hostTestMessage('first message') + const sent = host.send(caller, { envelope: envelope('agentSession.send', { body }), body }) + expect(script.wire()).not.toContain('set_model') + script.releaseHandshake() + expect((await Promise.all(picked)).every((result) => result.ok)).toBe(true) + expect((await sent).ok).toBe(true) + await vi.waitFor(() => expect(script.prompts()).toEqual(['first message'])) + const wire = script.wire() + expect(wire.indexOf('set_model')).toBeGreaterThanOrEqual(0) + expect(wire.indexOf('set_thinking_level')).toBeGreaterThan(wire.indexOf('set_model')) + expect(wire.indexOf('prompt:first message')).toBeGreaterThan(wire.indexOf('set_thinking_level')) + expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options).toMatchObject({ + model: 'scripted/model-2', + effort: 'low' + }) +}) + +it('ends Pi on Stop and resumes its real session file when the next send starts a child', async () => { + const { script, host, caller } = await setup() + script.completeTurns = false + script.releaseHandshake() + const first = hostTestMessage('start a long turn') + expect( + ( + await host.send(caller, { + envelope: envelope('agentSession.send', { body: first }), + body: first + }) + ).ok + ).toBe(true) + await vi.waitFor(() => expect(script.prompts()).toEqual(['start a long turn'])) + expect((await host.cancel(caller, { envelope: envelope('agentSession.cancel', {}) })).ok).toBe( + true + ) + await vi.waitFor(() => expect(script.closes()).toBe(1)) + const second = hostTestMessage('resume after Stop') + expect( + ( + await host.send(caller, { + envelope: envelope('agentSession.send', { body: second }), + body: second + }) + ).ok + ).toBe(true) + await vi.waitFor(() => expect(script.spawns()).toBe(2)) + expect(script.resumes()).toBe(1) + expect(script.prompts()).toEqual(['start a long turn']) + script.releaseHandshake() + await vi.waitFor(() => + expect(script.prompts()).toEqual(['start a long turn', 'resume after Stop']) + ) +}) diff --git a/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts b/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts new file mode 100644 index 00000000000..e6cd3762a51 --- /dev/null +++ b/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts @@ -0,0 +1,236 @@ +// Protocol operations wait for the real Codex adapter to open; only its child is scripted. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../shared/agent-session-wire' +import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { + CODEX_SCRIPTED_THREAD, + codexScriptedChild +} from '../codex/codex-scripted-child.test-fixture' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { ScriptedAgentChild } from './structured-agent-scripted-child.test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const CALLER = { callerKey: 'provider-operations-test' } +let root: string | undefined +let operations = 0 +/** The attach that opened the chat; a reconnecting view replays it. */ +let openedWith: Parameters[1] | undefined + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } +}) + +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +const eventually = (assertion: () => void | Promise) => + vi.waitFor(assertion, { timeout: 10_000 }) + +function envelope( + host: StructuredAgentSessionHost, + method: string, + fields: Record +): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function openChat( + child: ScriptedAgentChild, + definition: StructuredAgentDefinition = CODEX_STRUCTURED_AGENT +): Promise { + const { agent } = definition + root = await mkdtemp(join(tmpdir(), `orca-provider-operations-${agent}-`)) + const directory = root + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: directory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => directory, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + ...child.deps + }) + const params = hostTestAttachParams(null, { + provider: agent, + agent, + accountHome: + definition.accountLocatorKind === 'opencode' + ? { kind: 'opencode', locator: { kind: 'unmanaged' } } + : { variable: definition.accountHomeVariable, path: join(directory, agent) }, + providerHandle: undefined + }) + // The runtime admits operation ids against its own clock. + params.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, params) + expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + openedWith = params + return host +} + +async function send(host: StructuredAgentSessionHost, text: string) { + const body = hostTestMessage(text) + return host.send(CALLER, { envelope: envelope(host, 'agentSession.send', { body }), body }) +} + +/** A first turn the scripted Codex recorded, then the chat put to rest with a turn to target. */ +async function codexChatAtRestWithATurn(child: ReturnType) { + child.holdHandshakes = false + const host = await openChat(child) + child.releaseHandshake() + const first = await send(host, 'first turn') + if (!first.ok) { + throw new Error(JSON.stringify(first.refusal)) + } + const { clientMessageId } = first.value + let providerItemId: string | null = null + await eventually(async () => { + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + expect(submission?.providerItemId).toEqual(expect.any(String)) + providerItemId = submission?.providerItemId ?? null + }) + await host.close(SESSION, 'evict') + expect(child.closes()).toBe(1) + child.holdHandshakes = true + return { + host, + itemId: agentJournalSubmissionKey(clientMessageId), + providerItemId: providerItemId! + } +} + +/** Resolves to whether `pending` settled within a short wait. */ +async function settlesSoon(pending: Promise): Promise { + let settled = false + void pending.finally(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 50)) + return settled +} + +it('settles a rewind that goes in doubt while a send waits in the queue, by waiting out the start the send needs', async () => { + const child = codexScriptedChild({ + 'thread/read': () => ({ thread: { id: CODEX_SCRIPTED_THREAD, status: { type: 'idle' } } }), + 'thread/turns/list': () => ({ data: [], nextCursor: null }), + 'thread/items/list': () => ({ data: [], nextCursor: null }) + }) + const { host, itemId, providerItemId } = await codexChatAtRestWithATurn(child) + const { store } = host.deps + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + const queueHeld = Promise.withResolvers() + void host.collaboratorsForTests().serialize(SESSION, () => queueHeld.promise) + + // Nothing is in doubt when the send arrives; it waits in the queue. + const sent = send(host, 'after the rewind') + expect(await settlesSoon(sent)).toBe(false) + const request = envelope(host, 'agentSession.rewind', { itemId, expectedEpoch }) + await store.admitMutationOperation({ + callerKey: CALLER.callerKey, + envelope: request, + hostFingerprint: request.payloadFingerprint, + now: Date.now() + }) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + rewind: { + operationId: request.clientOperationId, + callerKey: CALLER.callerKey, + itemId, + providerItemId, + expectedEpoch, + phase: 'prepared', + retained: [] + } + })) + queueHeld.resolve() + + // The send starts the agent to settle the rewind, and waits that start out. + await eventually(() => expect(child.spawns()).toBe(2)) + expect(await settlesSoon(sent)).toBe(false) + child.releaseHandshake() + + expect(await sent).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') + await eventually(() => expect(child.prompts()).toEqual(['first turn', 'after the rewind'])) +}) + +it('answers a rewind asked of a resting Codex chat from the thread it opens, leaving nothing in doubt', async () => { + const child = codexScriptedChild({ + 'thread/resume': (params) => ({ + thread: { id: params?.threadId, historyMode: 'legacy' }, + model: 'scripted-model' + }) + }) + const { host, itemId } = await codexChatAtRestWithATurn(child) + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + + const rewound = host.rewind(CALLER, { + itemId, + expectedEpoch, + envelope: envelope(host, 'agentSession.rewind', { itemId, expectedEpoch }) + }) + await eventually(() => expect(child.spawns()).toBe(2)) + expect(await settlesSoon(rewound)).toBe(false) + child.releaseHandshake() + + // Only the opened thread says its history cannot be rewound. + expect(await rewound).toMatchObject({ + ok: false, + refusal: { rewindReason: 'history-not-paginated' } + }) + expect(host.deps.store.getRecord(SESSION)?.rewind).toBeUndefined() + expect(await send(host, 'still sends')).toMatchObject({ ok: true }) + await eventually(() => expect(child.prompts()).toEqual(['first turn', 'still sends'])) +}) + +it('keeps a goal waiting on a starting Codex chat through a re-attach, then sets it once the thread is open', async () => { + const child = codexScriptedChild() + const host = await openChat(child) + const change = { kind: 'set' as const, objective: 'Ship the release' } + + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope(host, 'agentSession.threadGoal', { change }), + change + }) + expect(await settlesSoon(goal)).toBe(false) + // A view reconnects while the chat is still starting: its replayed attach keeps the live child. + if (!openedWith) { + throw new Error('the chat was not opened') + } + expect(await host.attach(CALLER, openedWith)).toMatchObject({ ok: true, replayed: true }) + expect(child.spawns()).toBe(1) + expect(await settlesSoon(goal)).toBe(false) + child.releaseHandshake() + + expect(await goal).toMatchObject({ ok: true }) + expect(child.requests().some(({ method }) => method === 'thread/goal/set')).toBe(true) +}) diff --git a/src/main/runtime/structured-agent-session-transport-overrides.ts b/src/main/runtime/structured-agent-session-transport-overrides.ts index b76393f0a8a..6bf042ca960 100644 --- a/src/main/runtime/structured-agent-session-transport-overrides.ts +++ b/src/main/runtime/structured-agent-session-transport-overrides.ts @@ -1,15 +1,24 @@ +import type { AcpStructuredSessionAdapterDeps } from '../acp/acp-structured-session-adapter-deps' +import type { AcpStructuredLaunchPartDeps } from '../acp/acp-structured-launch-resolution' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { readCodexCliInstallation } from '../preflight/codex-cli-installation' import type { StructuredAgentSessionStartupLimits } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' import type { PiRpcSessionDeps } from '../pi/rpc-session' +import type { PiRpcSessionAdapterDeps } from '../pi/rpc-session-adapter' /** Provider transports are overridden only to drive the runtime against scripted children. */ export type StructuredAgentSessionTransportOverrides = { openPiConnection?: PiRpcSessionDeps['openConnection'] + /** Scripted Pi children resolve their launch here, so no test reaches a real Pi binary. */ + resolvePiLaunch?: PiRpcSessionAdapterDeps['resolveLaunch'] openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] /** Scripted transports must also replace the real host's version probe. */ readCodexInstallation?: typeof readCodexCliInstallation + openAcpConnection?: AcpStructuredSessionAdapterDeps['connect'] + /** Answers an ACP launch's command lookup and version probe for a scripted child; every launch + * still resolves and checks through them. Unset, the real binary is found and probed. */ + acpLaunchCommand?: Pick openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 933721ef697..4365c4d64c7 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -45,6 +45,7 @@ const THREAD = 'thread-switch' const REVIEWER = 'thread-reviewer' const ROUTES: Record = { 'thread/start': { thread: { id: THREAD } }, + 'turn/start': { turn: { id: 'p2', status: 'inProgress' } }, 'model/list': { data: [ { @@ -133,7 +134,10 @@ describe('the chat strip and the session list read the same host child records', ...scriptedCodexTransport(openConnection), statusSink }) - const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + const attachParams = hostTestAttachParams(null, { + providerHandle: undefined, + options: { model: 'gpt-test' } + }) attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` const attached = await host.attach({ callerKey: 'switch-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) @@ -329,6 +333,13 @@ describe('the chat strip and the session list read the same host child records', body } ) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.handedOverAt + ).toBeDefined() + ) await notify('turn/started', { threadId: THREAD, turn: { id: 'p2', status: 'inProgress' } }) await notify('item/started', { threadId: THREAD, diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index b776140c68d..6e9a2f147c9 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -188,7 +188,8 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { interrupt: async () => undefined, cancelAsyncMessage: async () => false, stopTask: async () => {}, - send: async () => { + send: async (_message, beforeDispatch) => { + await beforeDispatch?.() child.calls.push('send') }, close: async () => { diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index e292c271e3e..4056bad66d5 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -102,6 +102,9 @@ describe('structured Codex child work through the production runtime', () => { attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) // Creating the session starts its child; nothing else has to keep it running. expect(connections).toHaveLength(1) const notify = (method: string, params: Record) => diff --git a/src/shared/agent-session-execution-options.ts b/src/shared/agent-session-execution-options.ts new file mode 100644 index 00000000000..23b4b690a93 --- /dev/null +++ b/src/shared/agent-session-execution-options.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' + +/** Choices fixed when a message is accepted. Permission remains a launch setting. Unknown keys are + * stripped: a send's `options` record and a saved row are free-form, and only these keys apply. */ +export const AgentSessionExecutionOptionsSchema = z.object({ + model: z.string().min(1).max(256).optional(), + effort: z.string().min(1).max(64).optional(), + personality: z.string().min(1).max(64).optional(), + fastMode: z.enum(['true', 'false']).optional(), + serviceTier: z.string().min(1).max(64).optional() +}) + +export type AgentSessionExecutionOptions = z.infer + +/** The one read of a message's `options` record, from a send or a saved row. */ +export function readAgentSessionExecutionOptions( + value: unknown +): AgentSessionExecutionOptions | null { + const parsed = AgentSessionExecutionOptionsSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +/** The `options` record a send and a saved row carry for these choices. */ +export function agentSessionExecutionOptionsRecord( + options: AgentSessionExecutionOptions +): Record { + return Object.fromEntries( + Object.entries(options).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) +} diff --git a/src/shared/agent-session-journal-submission-schema.ts b/src/shared/agent-session-journal-submission-schema.ts index 08631c00157..aae443cb003 100644 --- a/src/shared/agent-session-journal-submission-schema.ts +++ b/src/shared/agent-session-journal-submission-schema.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema' import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' import type { AgentJournalSubmission } from './agent-session-journal-types' +import { AgentSessionExecutionOptionsSchema } from './agent-session-execution-options' // Every persisted field is listed, or a parse strips it: this schema drops unknown keys. export const AgentJournalSubmissionSchema = z.object({ @@ -27,7 +28,7 @@ export const AgentJournalSubmissionSchema = z.object({ // The kind only; an object's other keys (a sender) are stripped, never published. source: z.object({ kind: z.string() }).optional(), keptAsQueuedMessageId: z.string().min(1).optional(), - options: z.record(z.string(), z.string()).optional() + options: AgentSessionExecutionOptionsSchema.optional() }) export function isAdmissibleAgentJournalSubmission( diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 1f556e92252..4e969014736 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -19,6 +19,7 @@ import type { AgentSessionContextUsage } from './agent-session-context-usage' import type { AgentSessionProviderHandle } from './agent-session-provider-handle' import type { NativeChatBlock, NativeChatRole } from './native-chat-types' import type { AgentMessageSource } from './agent-session-message-source' +import type { AgentSessionExecutionOptions } from './agent-session-execution-options' export { type AgentType } @@ -517,9 +518,9 @@ export type AgentJournalSubmission = { * card, so no surface draws this send, before or after the card is sent, edited or deleted. * Recorded in the rejection's own transaction (`journal-unsent-send-hold.ts`). */ keptAsQueuedMessageId?: string - /** The model and effort the person's composer sent it with, applied when it is handed over. - * The host's alone: clients ignore it. */ - options?: Readonly> + /** Immutable choices captured on acceptance and applied when the agent is handed the message; + * absent on older submissions. The host's alone: clients ignore it. */ + options?: AgentSessionExecutionOptions } /** Durable answer to "did my send land?", keyed by client message id. Only an diff --git a/src/shared/agent-session-options-result.ts b/src/shared/agent-session-options-result.ts new file mode 100644 index 00000000000..1064d95daf5 --- /dev/null +++ b/src/shared/agent-session-options-result.ts @@ -0,0 +1,54 @@ +import type { AgentJournalCursor, AgentJournalThreadGoal } from './agent-session-journal-types' +import type { AgentSessionConversationCommand } from './agent-session-conversation-command' +import type { AgentSessionContextUsage } from './agent-session-context-usage' +import type { AgentSessionRewindSupport } from './agent-session-rewind' +import type { + AgentSessionFastModeState, + AgentSessionFastModeSupport, + AgentSessionModelOption +} from './agent-session-wire' + +/** Provider-reported choices and effective next-turn values. Additive read-only + * surface so older hosts can reject it without changing structured v1 writes. */ +export type AgentSessionOptionsResult = { + rewind?: AgentSessionRewindSupport + conversationCommands?: readonly AgentSessionConversationCommand[] + /** Present only where this session can change its goal, so a host without + * `agentSession.threadGoal` never offers the controls. `current` is the + * latest goal the whole journal records, for a client whose loaded page + * starts after it. */ + threadGoal?: { current: AgentJournalThreadGoal | null; contextFloor?: AgentJournalCursor } + /** The goal a session's journal records where only the agent's own `/goal` command changes it. + * Separate from `threadGoal`, so a client that predates it never offers host controls. */ + reportedThreadGoal?: { current: AgentJournalThreadGoal; contextFloor?: AgentJournalCursor } + /** Present only where this session writes context facts to its turn rows. + * `current` is the newest of each part the whole journal records, for a + * client whose loaded page starts after the row that carries it. */ + contextUsage?: { current: AgentSessionContextUsage; contextFloor?: AgentJournalCursor } + /** This session takes image attachments. Absent = unknown (an older host). */ + acceptsImages?: boolean + models: AgentSessionModelOption[] + /** The `models` ids the agent's own finished listing names, excluding host-added rows and + * unverified launch-only rows. Absent: nothing verified, as from an older host. */ + listedModelIds?: string[] + /** Session/account/transport support. Absent means unknown, never unsupported. */ + fastModeSupport?: AgentSessionFastModeSupport + current: { + model?: string + effort?: string + /** Canonical preference for the next turn. Explicit false is meaningful. */ + fastMode?: boolean + /** Next-turn service tier id where the model lists `serviceTiers`; `default` is standard. */ + serviceTier?: string + /** The permission mode a start runs now; absent from a host or agent with no picker. */ + permissionMode?: string + /** Provider-reported effective routing, distinct from the next-turn preference. */ + fastModeState?: AgentSessionFastModeState + /** + * Option ids whose value the provider reported back, not merely accepted. + * Optional: a host that predates it sends nothing and the client keeps + * treating the value as unconfirmed, which is what it was before. + */ + confirmed?: readonly string[] + } +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index d7c079a7487..27d1b379537 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -5,7 +5,7 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskState } from './agent-session-background-task-wire' -import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind' +import type { AgentSessionRewindReason } from './agent-session-rewind' import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' import type { AgentChildWorkView } from './agent-status-child-work-view' import type { @@ -13,11 +13,10 @@ import type { AgentSessionQueuePause } from './agent-session-queued-message-wire' +export type { AgentSessionOptionsResult } from './agent-session-options-result' export * from './agent-session-wire-refusals' export * from './agent-session-queued-message-wire' export * from './agent-session-turn-completion-wire' -import type { AgentSessionConversationCommand } from './agent-session-conversation-command' -import type { AgentSessionContextUsage } from './agent-session-context-usage' // ─── Structured agent-session wire contract ───────────────────────────────── // The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider // adapters and clients against exactly these types, so everything here must be @@ -31,7 +30,6 @@ import type { AgentJournalResetReason, AgentJournalResolution, AgentJournalSubmission, - AgentJournalThreadGoal, AgentJournalTurnLifecycle } from './agent-session-journal-types' import type { AgentTurnOutcome } from './agent-turn-outcome' @@ -459,51 +457,3 @@ export type AgentSessionSlashCommand = { * surface: a host that predates it answers `method_not_found`, and the client * keeps rendering its curated catalog. */ export type AgentSessionCommandsResult = { commands?: AgentSessionSlashCommand[] } - -/** Provider-reported choices and effective next-turn values. Additive read-only - * surface so older hosts can reject it without changing structured v1 writes. */ -export type AgentSessionOptionsResult = { - rewind?: AgentSessionRewindSupport - conversationCommands?: readonly AgentSessionConversationCommand[] - /** Present only where this session can change its goal, so a host without - * `agentSession.threadGoal` never offers the controls. `current` is the - * latest goal the whole journal records, for a client whose loaded page - * starts after it. */ - threadGoal?: { current: AgentJournalThreadGoal | null; contextFloor?: AgentJournalCursor } - /** The goal a session's journal records where only the agent's own `/goal` command changes it. - * Separate from `threadGoal`, so a client that predates it never offers host controls. */ - reportedThreadGoal?: { current: AgentJournalThreadGoal; contextFloor?: AgentJournalCursor } - /** Present only where this session writes context facts to its turn rows. - * `current` is the newest of each part the whole journal records, for a - * client whose loaded page starts after the row that carries it. */ - contextUsage?: { - current: AgentSessionContextUsage - contextFloor?: AgentJournalCursor - } - /** This session takes image attachments. Absent = unknown (an older host). */ - acceptsImages?: boolean - models: AgentSessionModelOption[] - /** The `models` ids the agent's own finished listing names, excluding host-added rows and - * unverified launch-only rows. Absent: nothing verified, as from an older host. */ - listedModelIds?: string[] - /** Session/account/transport support. Absent means unknown, never unsupported. */ - fastModeSupport?: AgentSessionFastModeSupport - current: { - model?: string - effort?: string - /** Canonical preference for the next turn. Explicit false is meaningful. */ - fastMode?: boolean - /** Next-turn service tier id where the model lists `serviceTiers`; `default` is standard. */ - serviceTier?: string - /** The permission mode a start runs now; absent from a host or agent with no picker. */ - permissionMode?: string - /** Provider-reported effective routing, distinct from the next-turn preference. */ - fastModeState?: AgentSessionFastModeState - /** - * Option ids whose value the provider reported back, not merely accepted. - * Optional: a host that predates it sends nothing and the client keeps - * treating the value as unconfirmed, which is what it was before. - */ - confirmed?: readonly string[] - } -} diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index aece5f4ac65..b69f44a1589 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -144,8 +144,8 @@ export const SendParams = z * older host refuses it: clients send it only when `agent-session.queued-messages.v1` is * advertised. Participates in the operation fingerprint, never the body fingerprint. */ delivery: z.literal('queue-if-active').optional(), - /** The composer's model and effort for the turn this send starts; the host records them on the - * chat. Sent only when `agent-session.send-options.v1` is advertised. Operation fingerprint only. */ + /** The composer's choices for the turn this message starts, kept with the message until it is + * dispatched. Sent only when `agent-session.send-options.v1` is advertised. Operation fingerprint only. */ options: AgentSessionOptions.optional(), body: SendBody }) diff --git a/src/shared/structured-agent-session-send-mutation.spec.ts b/src/shared/structured-agent-session-send-mutation.spec.ts index 031cfc773f3..e30a202f6f2 100644 --- a/src/shared/structured-agent-session-send-mutation.spec.ts +++ b/src/shared/structured-agent-session-send-mutation.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from './agent-session-journal-types' import type { AgentMessageSource } from './agent-session-message-source' +import { AgentJournalSubmissionSchema } from './agent-session-journal-submission-schema' import { SendParams } from './rpc-contract/structured-agent-session-params' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { @@ -26,6 +27,41 @@ const FROM: AgentMessageSource = { } describe('send fingerprints never cover the sender', () => { + it('fingerprints message choices with the operation, never the body', () => { + const send = (options?: { model: string; effort: string }) => + structuredAgentSessionMessageSendMutation({ + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + body: BODY, + ...(options ? { options } : {}) + }) + const legacy = send() + const chosen = send({ model: 'model-a', effort: 'high' }) + expect(chosen.envelope.payloadFingerprint).not.toBe(legacy.envelope.payloadFingerprint) + expect(chosen.options).toEqual({ model: 'model-a', effort: 'high' }) + expect(SendParams.safeParse(legacy).success).toBe(true) + expect(SendParams.safeParse(chosen).success).toBe(true) + }) + + it('reads a saved submission’s choices, ignoring keys an older build stored', () => { + const submission = { + clientMessageId: 'message-1', + options: { model: 'model-a', unknownChoice: 'kept-by-an-older-build' }, + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2 + } + expect(AgentJournalSubmissionSchema.parse(submission).options).toEqual({ model: 'model-a' }) + expect( + AgentJournalSubmissionSchema.omit({ options: true }).parse(submission) + ).not.toHaveProperty('options') + }) + it('keeps the digest every stored submission and draft already holds', () => { // Pinned bytes: a stored fingerprint that changed would stop matching its provider echo. expect(agentSessionSendBodyFingerprint('session-1', BODY)).toBe( diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts index 5d79753797e..87a1e2ef72b 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts @@ -4,6 +4,7 @@ // operation conflict, loses the user's message. import { beforeAll, describe, expect, it } from 'vitest' +import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { resolveBaselineReleaseRef } from './release-checkout' import { installableHost, structuredHostStub } from './structured-agent-session-host-fixture' import { @@ -20,6 +21,9 @@ import { type SentMessage } from './versioned-agent-session-wire' +// The first published release whose host advertises `agent-session.send-options.v1` (#27826). +const SEND_OPTIONS_RELEASE_REF = 'v1.4.228' + // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -82,3 +86,37 @@ describe('a current client sending a message', () => { } }) }) + +describe('a current client sending a message with its choices', () => { + it( + 'is accepted, and the choices reach the host, on a host that advertises them', + async () => { + const release = await loadAgentSessionWireBuild(SEND_OPTIONS_RELEASE_REF) + const options = { model: 'sonnet', effort: 'high' } + for (const build of [current, release]) { + expect(build.capabilities, build.label).toContain( + AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY + ) + resetOperationIds() + const hostCalls = structuredHostStub(SESSION, WORKSPACE) + await build.installStructuredHost(installableHost(hostCalls)) + try { + const replies = await send(build, sendParams('hi', 1, undefined, options)) + expect(replies, `${build.label}: ${JSON.stringify(replies)}`).toMatchObject([ + { ok: true } + ]) + const sent: SentMessage = hostCalls.send.mock.calls[0]?.[1] + expect(sent.options, `${build.label}: choices reached the host`).toEqual(options) + // `options` joins the operation fingerprint on both builds, so admission gets past it. + expect(await build.admitSend(sent), `${build.label}: send admission`).toMatchObject({ + ok: false, + refusal: { details: { reason: 'sessionNotAttached' } } + }) + } finally { + await build.installStructuredHost(null) + } + } + }, + SUITE_TIMEOUT_MS + ) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index c8691c7b739..e9232451a36 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -704,7 +704,7 @@ describe('cross-version structured agent sessions', () => { starts += 1 await startGate return { - providerChildPhase: 'starting' as const, + acquisitionGeneration: `cursor-${fence}`, process: { hostId: 'local', pid: 4242, @@ -743,7 +743,7 @@ describe('cross-version structured agent sessions', () => { /** Reopens the store from disk and installs a fresh host over the same journal * root — what a process restart actually leaves behind. */ - async function bootHost(generation: string, startupLimits?: { silenceMs: number }) { + async function bootHost(generation: string, startupLimits?: { ceilingMs: number }) { store = await openTestAgentSessionRecordStore(root) const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, @@ -943,7 +943,9 @@ describe('cross-version structured agent sessions', () => { (capability) => capability !== AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY ) const created = await answer('agentSession.create', createIntentParams()) - await bootHost('b', { silenceMs: 200 }) + // The ceiling counts from the start's registration, so a short one ends a start whose + // handshake never answers without waiting out the real ten minutes. + await bootHost('b', { ceilingMs: 200 }) handshake = new Promise(() => undefined) const params = sendParams('never started', created.fence) diff --git a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts index 9ed7e4d56e1..68c4c8ab433 100644 --- a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts @@ -37,7 +37,8 @@ import { parseWorkspaceSession } from '../../../src/shared/workspace-session-sch import { importReleaseCheckoutModule, materializeReleaseCheckout, - resolveBaselineReleaseRef + resolveBaselineReleaseRef, + type ReleaseCheckout } from './release-checkout' import { loadAgentSessionWireBuild, @@ -81,6 +82,27 @@ function member(module: Record, name: string): T { return value as T } +// v1.4.228 moved `isStructuredTab` to shared code; keeping both locations lets the rolling baseline +// cross that move and an older pinned ref still load. +const BASELINE_STRUCTURED_TAB_SOURCES = [ + 'src/shared/structured-agent-session-tab.ts', + 'src/renderer/src/components/native-chat/structured-agent-session-tabs.ts' +] as const + +async function importBaselineStructuredTab( + checkout: ReleaseCheckout +): Promise> { + for (const file of BASELINE_STRUCTURED_TAB_SOURCES) { + const loaded = await importReleaseCheckoutModule(checkout, file).catch(() => null) + if (loaded?.isStructuredTab !== undefined) { + return loaded + } + } + throw new Error( + `the baseline release exports no isStructuredTab (tried ${BASELINE_STRUCTURED_TAB_SOURCES.join(', ')})` + ) +} + beforeAll(async () => { const ref = resolveBaselineReleaseRef() const checkout = await materializeReleaseCheckout(ref) @@ -91,8 +113,7 @@ beforeAll(async () => { 'src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts' ), importReleaseCheckoutModule(checkout, 'src/shared/workspace-session-schema.ts'), - // Why: releases from v1.4.228 define it here; the renderer module only imports it. - importReleaseCheckoutModule(checkout, 'src/shared/structured-agent-session-tab.ts'), + importBaselineStructuredTab(checkout), importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') ]) current = await loadAgentSessionWireBuild(WORKING_TREE) diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts index 68d7f39c89b..6ac40703d6a 100644 --- a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeAll, beforeEach, expect } from 'vitest' import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { startsWhenPublished } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' @@ -83,23 +84,33 @@ export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { claimKeyId: 'key-1', logger: createStructuredAgentSessionLogger(), now: () => NOW, - adapter: { - supportsCreate: () => true, - acquire: async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ state: 'unknown', reason: 'unused' }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => {}, - setOption: async () => {} - } + // A ready provider proves its start as soon as it is published. + adapter: startsWhenPublished( + { + supportsCreate: () => true, + acquire: async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `stop-note-${fence}`, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ state: 'unknown', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => {}, + setOption: async () => {} + }, + () => host + ) }) setStructuredAgentSessionHost(host) expect(await host.attach({ callerKey: 'released-client' }, attachParams(null))).toMatchObject({ diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 18b834508b1..2e6951632c7 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -186,6 +186,7 @@ export function readyCodexProviderAdapter(input: { return startsWhenPublished( { acquire: async ({ fence }) => ({ + acquisitionGeneration: `wire-host-${fence}`, process: { hostId: 'local', pid: 4242, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 5ad14f950f1..4dd014ce155 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -108,14 +108,16 @@ export function createIntentParams(): Record { export function sendParams( text: string, fence: number, - sentDelivery?: 'queue-if-active' + sentDelivery?: 'queue-if-active', + options?: Readonly> ): Record { return structuredAgentSessionMessageSendMutation({ sessionId: SESSION, clientOperationId: operationId(), expectedRuntimeFence: fence, body: structuredAgentSessionSendBody(text, []), - ...(sentDelivery ? { delivery: sentDelivery } : {}) + ...(sentDelivery ? { delivery: sentDelivery } : {}), + ...(options ? { options } : {}) }) } diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index bc02eefb2cb..de0ce6d5d98 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -47,7 +47,7 @@ function openHost(): void { adapter: startsWhenPublished( { acquire: async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, + acquisitionGeneration: `generation-${fence}`, process: { hostId: 'local', pid: 4242,