From 53191250e87aceff52a7956c19001622aa48cfcd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:53:46 -0700 Subject: [PATCH] Accept messages while any agent starts, and bound each startup step (#28046) * Finish structured agent readiness before delivering held work * Publish the Codex process before protocol startup * Publish ACP agent processes before session startup * Publish Pi at spawn and settle signed-out starts once * Adapt permission fixtures to startup lifecycle * Keep model discovery for chats before their first start * Use one host readiness promise for provider operations * Await provider opening in the execution host * Await Claude initialization in host-owned open * Observe Claude readiness through the host status * Keep optional readback behind host readiness * Recheck launch permission after host readiness * Preserve the awaited child during permission revalidation * Await Codex protocol open under host ownership * Await ACP protocol open in the host * Derive ACP opening from its protocol session facts * Exercise unanswered ACP options with an explicit silent route * Await Pi open and fail signed-out construction * Read Pi catalog facts from awaited opening * Keep the Pi fixture exit observer * Record failed owned opening before the first message * Abort a matching provider open when its child exits * Cover provider exit during an unanswered host open * Cover explicit failure facts on a host-stopped child * Capture message choices per accepted send and preserve picker state * Apply accepted Claude message choices at prompt handover * Apply captured message choices at provider dispatch * Bind native chat choices to accepted message dispatch * Reset Codex turn choices for each captured message * Resolve Codex message defaults from native facts * Align message choice and Pi default fixtures with provider dispatch * Align option fixtures with saved message choices * Match Codex message choices to the active turn origin * fix: let pending commands reach handover refusal * Abort pre-prompt message choice writes with host-owned wait * Verify message choices through provider input boundaries * Keep legacy mobile test hosts explicit about message choices * Keep startup waits cancellable and preserve operation order * Derive Codex startup exits from host phase * Open Codex protocol in background-task test fixtures * Keep ACP startup ownership until handshake completes * Run ACP opening catalog fixtures with Node SQLite * Verify saved ACP picks without live option writes * Keep ACP catalog bookkeeping from failing an options read * Restore signed-out restart and fail Pi startup faults * Run Pi opening catalog fixtures with Node SQLite * Preserve message choices across cold clients and kept cards * Remove the unused provider option restore ledger * Clarify launch choices and legacy option hydration * Remove remaining restore-ledger forwarding and assertions * Remove unused ACP and Pi restore bookkeeping * Keep live choices and delivery order while agents start * Drop retired startup revision from Codex opening * Keep live-option cancellation until message choices and clarify catalog reads * Hold ACP option replies in live-write cancellation regressions * Remove retired Pi startup revision and match saved-pick behavior * Check running choices and retained rewind messages after delivery integration * Apply startup picks and let parked messages release operations [skip ci] * Remove unused Codex opening report field [skip ci] * Keep ACP restoration facts local to the provider [skip ci] * Apply Pi startup picks after readiness and remove unused open facts [skip ci] * Cover goal changes during the held Codex message wait [skip ci] * Save a waiting startup pick after Stop ends its opening [skip ci] * Save choices when startup ends or a replacement is still opening * Check saved choices separately from Claude startup reports * Release scripted Codex startup before awaiting queued picks * Check the saved picker selection in per-message choice fixtures * Exercise current choices and eager client capabilities in runtime fixtures * Reuse runtime fixture reads and type the fake connection directly * Align startup fixtures and preserve observed Claude exits * Honor queued send authorization in Claude runtime fixtures * Open Codex protocol sessions before Stop fixture dispatch * Wait for the opened Codex child before runtime notifications * Keep repeated permission picks usable and align Codex fixtures * Validate pending picks against the live option state * Order startup picks before later messages and retain flow regressions * Keep reviewed lifecycle contracts while pruning duplicate tests * Save picks after a proven exit without requiring a child * Type the startup reply fixture against the Claude control contract * Keep Codex startup flow contracts and remove overlapping adapter tests * Restore the committed dependency lockfile after test tooling * Keep startup recovery flows and remove duplicate Pi handshake cases * Cover both Pi startup choices in the delivery flow * Match the Pi prompt entry recorded by the scripted wire * Honor the before-input callback in the startup reply fixture * Keep message choice flows and remove overlapping selector tests * Keep startup option menus independent and show the saved choice * Exercise startup picks across real menus and the runtime * Match startup picker fixture to saved message choices * Keep saved effort visible when picking a model during startup * Include the running-turn scope in repeated Stop fixture * Use the current attach contract in import fixtures * Keep the Codex journal translator within its line limit * Match ACP startup fixtures to saved picks and late Stop behavior * Check startup effort after saved picker choices are acknowledged * Complete Stop target types and keep the effort regression concise * Use requested picker values in the effort regression acknowledgement * Require owned provider spawn and one host-awaited open (from #28055) * Restore the live checks the startup cleanup's deleted tests pinned One assertion each, in the surviving suites: a stale-generation exit is ignored, a failed create lets its sink go, a readback before open is saved, an idle stop that fails is retried, a no-pick Pi start teaches its default, an older client's pre-spawn failure and packed answer, an undrivable chat stays readable, the resting slash menu stays on its own host, the main agent reads apart from subagents, restore publishes status, naming runs off the host's status feed, and a throwing logger never reaches the operation. Also corrects the reliability gate note that claimed the send-idempotency suite replaced the refusal-retry tests. * Read a rewind in doubt outside the chat's serialize and apply it in the commit The started settle and the attach's ready-child path read the provider's history first, then apply what it proves in a short commit that re-checks the rewind it read. * Accept messages while the agent starts: run the start outside the chat's serialize The chat's serialize was both where a send was admitted and where its agent started, so every slow step of a start (closing-child join, lease reconcile, recovery probes, resolvers, history read, spawn, sink drain, provider dispatch) held every later send. A start now registers in one short step before its first wait: its abort controller (which Stop, close and quit already reach outside the lock), its startup clock (the 10-minute ceiling now counts from here) and quit's drain. Every wait after runs outside the lock, raced with that signal, and every write is a commit that first re-checks the start is still the session's. A start that loses the check is late: its spawn is closed through the post-acquisition failure path and nothing is handed to it. - The delivery loop's prepare returns a start handle; the loop awaits the start outside, then re-prepares. Handover writes the pending row and begins the dispatch abort handle in one commit, calls the provider outside after re-checking the child, and resolves in a second commit. - Ready operations claim a start instead of running one in their step. - A client attach runs the same steps under one serialize, as before. - An exit reported between spawn and publish is held for the publish commit, which ends the child it indexes. - An unproven close still fails only what was queued when its start was asked for. * Test that a send is accepted at every wait an agent start makes One whole-flow test holds the start at each wait in turn (closing-child join, reconcile probe, recovery re-probe, resolver, spawn, sink drain, open, started recovery, pending row, provider call): a second send is accepted at once, a Stop is recorded without waiting and nothing reaches the provider, and what was sent goes to the published child once, in order. Also quit before spawn, concurrent sends, a lease left recovering, and an idle sweep racing a send. * Bound each startup step on its own and drop the startup silence rule A start no longer ends after 60 s without output: output is not protocol progress. Each protocol step now has its own bound where its handshake lives, and one 10-minute ceiling from registration stays as the outer bound. - Codex: initialize 15 s and thread start/resume on the connection's 30 s default again, instead of the startup ceiling. - ACP: initialize 60 s, authenticate 120 s, session new/load/resume/fork 300 s; turns stay unbounded and option writes keep their caller's bound. - Claude: the initialize answer has a 60 s bound; past it the open fails with the ordinary startup-failure words and the host ends the child. - The onOutput plumbing that only fed the silence rule is removed. * Count Pi ready once it reports its state Pi's start no longer waits for its model listing and slash commands. The open returns after get_state and the unknown-provider check; the listing then arrives as an options report (with the catalog listing the start resolved) and the commands as a later publish. An empty model list no longer fails the start as signed out: a signed-out Pi runs the unknown provider, which still fails it, and a prompt it cannot run is refused with the sign-in words by the existing prompt path. get_state keeps the connection's 30 s request bound instead of the startup ceiling. * Test that model and effort picked while a chat is created ride its first message A slow host's create reply arrives after the user picked a model and effort. The picker's view layers the launch's held picks over its seed and the host's saved selection, and every send carries what the picker shows, so the first message carries the picks whether the host's status names its selection or not, and while the last pick is still on its way to the host. * Start a phone's new empty chat on its first message The phone's new chat is an agent.launch with no prompt, which always created the chat with its agent started, while desktop chats start on the first message. The phone now advertises the create-without-start capability, and a launch with no prompt from a caller advertising it creates the chat at rest, keyed in the ledger by its create intent as a client's create is. Launches with a prompt, the CLI (which advertises nothing), orchestration workers and ensure still start at once; an older host ignores the capability and stays eager. * Take main's lockfile; the branch changes no dependencies * Restore main's exact text in files the merge only reformatted The pre-commit formatter rewrote 37 of main's files staged by the merge, though this branch never changed them, pushing two past the line limit. Committed without the hook because the hook would reformat main's text again; CI lints the result. * Fit the startup tests to the owned-spawn, host-awaited-open contract Test doubles gain the mandatory open and an acquisition generation, the removed started event gives way to the rig's proveStart, and two literal types are tightened for CI's typecheck. A Stop during a held start writes no host note on this branch: the taken-back send draws its own stopped-before-start row, which the startup-attempt test now asserts. * Fit the remaining CI checks to the startup limits and current releases - Run agent-launch-at-rest.test.ts in the SQLite Node runtime project. - Pin the send-options cross-version case to v1.4.228, the first published release with agent-session.send-options.v1; v1.4.226 was never pushed. - Load the baseline's isStructuredTab from src/shared (moved in v1.4.228), falling back to the renderer path for older baselines. - End the never-proven start with a short startup ceiling: the silence limit it relied on is gone, and the ceiling counts from registration. * Read the option stamp from the chat's one host-execution selection * fix: preserve startup authority and accepted agent choices * Retain dispatch authority and reconcile accepted options * fix(native-chat): preserve accepted choices and cancellation authority * Restore established client selection flow * Preserve provider output observation and typed startup fixtures * fix: preserve initial Claude launch choices and effort readback * test: complete Claude catalog session fixture * Preserve incoming main files after merge formatting * Preserve compact handover through startup merge * Type instant startup fixtures before their open is supplied * Keep browser sign-in under its existing expiry owner * Finish the main merge: retired-model display, start rewind cut, new tests A model the Claude CLI retired is reported again, so the picker shows what the child runs and the next send moves the chat off it. The start reads a rewind in doubt against the handle it answered with, the record its commit then applies to. Main's tests follow the start's open: the admission fixture holds a real turn, and a sign-in close during setup fails the open. * Type the start's rewind record as the store returns it * Type the Sleep test's held open as the provider open it replaces * Sleep counts a chat stopped once the start it aborted leaves it at rest A start runs outside the chat's lane, so Sleep's step can run before the start it aborted has settled; it then re-reads the chat once that start is done, instead of reporting it unverified. * Keep Sleep's start-abort bookkeeping beside the rest of Sleep's stop --- config/path-literal-baseline.txt | 4 - config/reliability-gates.jsonc | 14 +- config/scripts/vitest-node-runtime-files.mjs | 3 +- .../scripts/vitest-runtime-boundary.test.ts | 3 +- .../vitest-sqlite-database-runtime-files.mjs | 1 - .../scripts/vitest-sqlite-runtime-files.mjs | 46 +- .../mobile-runtime-client-capabilities.ts | 5 +- src/main/acp/acp-message-execution-options.ts | 57 ++ src/main/acp/acp-peer-limits.ts | 13 + src/main/acp/acp-prompt-content.ts | 2 +- src/main/acp/acp-response-settlement.ts | 3 +- .../acp/acp-scripted-child-runtime.test.ts | 88 ++ .../acp/acp-scripted-child.test-fixture.ts | 151 +++ .../acp-scripted-connection.test-fixture.ts | 93 ++ src/main/acp/acp-session-lifecycle.test.ts | 14 +- src/main/acp/acp-structured-acquire.ts | 252 ++--- .../acp-structured-adapter.test-support.ts | 159 ++- .../acp/acp-structured-antigravity.test.ts | 9 +- src/main/acp/acp-structured-auth.test.ts | 6 +- .../acp/acp-structured-broken-stream.test.ts | 8 +- .../acp/acp-structured-child-lifecycle.ts | 140 +++ src/main/acp/acp-structured-child-stop.ts | 6 +- src/main/acp/acp-structured-child.ts | 63 ++ .../acp/acp-structured-compaction.test.ts | 34 +- src/main/acp/acp-structured-handshake.ts | 172 ++++ ...structured-host-configured-default.test.ts | 4 + .../acp/acp-structured-host-lifecycle.test.ts | 46 +- .../acp-structured-host-live-listing.test.ts | 14 +- .../acp-structured-host-option-hang.test.ts | 124 ++- ...acp-structured-host-restore-failed.test.ts | 49 +- .../acp-structured-host-start-aborts.test.ts | 5 +- .../acp-structured-host-start-failure.test.ts | 31 + .../acp/acp-structured-host.test-support.ts | 22 +- .../acp/acp-structured-missing-model.test.ts | 22 +- src/main/acp/acp-structured-options.test.ts | 15 +- src/main/acp/acp-structured-options.ts | 88 +- .../acp-structured-publish-at-spawn.test.ts | 203 ++++ ...ructured-session-adapter-lifecycle.test.ts | 119 +-- ...-structured-session-adapter-resume.test.ts | 26 +- ...tructured-session-adapter-teardown.test.ts | 2 +- .../acp-structured-session-adapter.test.ts | 63 +- .../acp/acp-structured-session-adapter.ts | 248 +++-- src/main/acp/acp-structured-session.ts | 9 +- src/main/acp/acp-structured-sign-in.test.ts | 41 +- src/main/acp/acp-structured-steer.test.ts | 4 +- src/main/acp/acp-turn-failures.ts | 18 +- .../agent-launch/agent-launch-executor.ts | 3 +- .../agent-launch-surface-factories.ts | 3 + .../claude/claude-agent-sdk-process-spawn.ts | 6 +- .../claude-api-retry-idle-sweep.test.ts | 3 + ...claude-context-usage-unloaded-turn.test.ts | 10 +- .../claude-stream-json-connection.test.ts | 1 + .../claude/claude-stream-json-connection.ts | 4 +- .../claude-structured-acquisition-launch.ts | 1 - ...claude-structured-dispatch-test-support.ts | 2 +- src/main/claude/claude-structured-dispatch.ts | 16 + ...-structured-effort-default-at-rest.test.ts | 6 +- ...claude-structured-effort-reporting.test.ts | 9 +- .../claude-structured-message-options.test.ts | 233 +++++ .../claude-structured-message-options.ts | 100 ++ ...aude-structured-model-confirmation.test.ts | 1 + .../claude-structured-model-preflight.test.ts | 2 +- ...ude-structured-option-confirmation.test.ts | 1 + ...-structured-options-context-window.test.ts | 1 - .../claude/claude-structured-options.test.ts | 4 +- src/main/claude/claude-structured-options.ts | 11 + .../claude-structured-retired-model.test.ts | 5 +- .../claude/claude-structured-retired-model.ts | 6 +- .../claude-structured-session-acquisition.ts | 11 +- ...e-structured-session-adapter-turns.test.ts | 19 + .../claude-structured-session-adapter.test.ts | 88 +- .../claude-structured-session-adapter.ts | 11 +- ...claude-structured-session-commands.test.ts | 17 + .../claude-structured-session-options.ts | 19 + .../claude-structured-session-publication.ts | 5 +- .../claude-structured-session-startup.test.ts | 62 +- .../claude-structured-session-startup.ts | 51 +- .../claude/claude-structured-session-state.ts | 9 +- .../claude-structured-spawn-options.test.ts | 5 - .../claude/claude-structured-spawn-options.ts | 30 +- .../claude-structured-start-fast-mode.test.ts | 35 +- .../claude-structured-start-fast-mode.ts | 70 -- .../codex-connection-app-server.ts | 30 + .../codex-app-server-connection-types.ts | 2 - .../codex/codex-app-server-connection.test.ts | 31 +- src/main/codex/codex-app-server-connection.ts | 37 +- .../codex/codex-background-terminals.test.ts | 5 +- .../codex/codex-collab-call-frames-fixture.ts | 9 +- .../codex/codex-configured-launch-defaults.ts | 11 +- .../codex-default-mode-subagents.test.ts | 9 +- .../codex-provider-retry-idle-sweep.test.ts | 3 + .../codex/codex-provider-retry-row.test.ts | 9 +- .../codex-scripted-child.test-fixture.ts | 177 ++++ ...dex-structured-child-work-producer.test.ts | 9 +- ...codex-structured-dispatch-boundary.test.ts | 100 ++ .../codex-structured-dispatch-test-support.ts | 3 +- .../codex-structured-journal-translation.ts | 10 +- .../codex-structured-launch-resolution.ts | 2 +- ...x-structured-live-provider-process.test.ts | 9 +- .../codex-structured-message-options.test.ts | 253 +++++ .../codex/codex-structured-message-options.ts | 99 ++ ...-structured-option-session.test-fixture.ts | 31 + .../codex-structured-prompt-ownership.test.ts | 39 +- .../codex-structured-question-order.test.ts | 267 ----- .../codex-structured-service-tier.test.ts | 31 +- .../codex/codex-structured-session-acquire.ts | 171 ++-- ...odex-structured-session-adapter-fixture.ts | 72 +- ...ructured-session-adapter-lifecycle.test.ts | 27 +- .../codex-structured-session-adapter.test.ts | 186 ++-- .../codex/codex-structured-session-adapter.ts | 68 +- ...tructured-session-background-tasks.test.ts | 9 +- .../codex-structured-session-cancel.test.ts | 3 +- .../codex-structured-session-close.test.ts | 24 +- .../codex/codex-structured-session-close.ts | 29 +- ...odex-structured-session-exit-blame.test.ts | 14 +- .../codex-structured-session-options.test.ts | 57 +- .../codex/codex-structured-session-options.ts | 17 + .../codex-structured-session-prompts.test.ts | 15 +- .../codex-structured-session-shutdown.test.ts | 7 +- .../codex/codex-structured-session-start.ts | 171 ++++ .../codex/codex-structured-session-state.ts | 25 +- .../codex-structured-session-teardown.ts | 12 +- .../codex-structured-thread-goal.test.ts | 21 + .../codex/codex-structured-thread-goal.ts | 5 +- .../codex/codex-structured-thread-open.ts | 12 +- .../codex/codex-structured-turn-open-wait.ts | 9 +- src/main/codex/codex-structured-turn-start.ts | 69 +- .../codex/codex-structured-turn-steer.test.ts | 6 +- .../codex/codex-structured-visuals.test.ts | 13 +- .../codex/codex-subagent-interrupts.test.ts | 3 +- src/main/jsonl-rpc/agent-connection.ts | 18 +- .../agent-model-catalog-entry.ts | 6 +- .../journal-queued-messages.ts | 3 +- .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 7 +- .../journal-store-contracts.ts | 5 +- .../journal-submission-fold.ts | 7 +- .../queued-message-stored-row.ts | 10 +- .../queued-message-table.ts | 7 +- ...-agent-session-accept-then-deliver.test.ts | 64 +- ...structured-agent-session-acquire-aborts.ts | 50 +- ...ed-agent-session-acquire-isolation.test.ts | 1 + ...-agent-session-acquisition-options.test.ts | 708 ------------- ...-session-acquisition-runtime-stamp.test.ts | 127 --- .../structured-agent-session-acquisition.ts | 74 +- ...nt-session-adapter-router-registry.test.ts | 213 ---- ...tured-agent-session-adapter-router.test.ts | 28 +- ...structured-agent-session-adapter-router.ts | 26 +- .../structured-agent-session-adapter.test.ts | 83 -- .../structured-agent-session-adapter.ts | 86 +- ...tured-agent-session-adopted-import.test.ts | 5 + .../structured-agent-session-agent-start.ts | 272 +++-- ...ured-agent-session-append-delivery.test.ts | 5 +- ...red-agent-session-at-rest-commands.test.ts | 182 +--- ...structured-agent-session-attach-failure.ts | 30 + .../structured-agent-session-attach-flow.ts | 147 ++- .../structured-agent-session-attach-lock.ts | 18 + ...ured-agent-session-attach-orchestration.ts | 242 ++--- ...structured-agent-session-attach-publish.ts | 138 +++ ...ructured-agent-session-chat-stop-target.ts | 3 +- .../structured-agent-session-chat-stop.ts | 40 +- .../structured-agent-session-child-close.ts | 30 +- .../structured-agent-session-child-exit.ts | 37 +- ...agent-session-child-work-selection.test.ts | 41 +- ...-agent-session-claude-option-queue.test.ts | 187 ---- ...red-agent-session-claude-root-exit.test.ts | 2 +- ...ssion-claude-stop-exit-ends-record.test.ts | 4 +- ...ured-agent-session-codex-held-goal.test.ts | 100 ++ ...-agent-session-codex-stop-note-end.test.ts | 2 +- ...tured-agent-session-codex-stop-row.test.ts | 2 +- ...d-agent-session-conversation-close.test.ts | 264 ----- ...red-agent-session-conversation-lifetime.ts | 20 +- ...ed-agent-session-conversation-stop.test.ts | 28 +- ...ured-agent-session-crash-mid-start.test.ts | 90 +- ...tured-agent-session-crash-turn-end.test.ts | 14 + .../structured-agent-session-create.test.ts | 6 +- .../structured-agent-session-create.ts | 4 +- .../structured-agent-session-delivery-loop.ts | 190 ++-- ...structured-agent-session-delivery-order.ts | 31 +- ...ructured-agent-session-drivability.test.ts | 348 ------- ...structured-agent-session-event-recovery.ts | 9 +- ...uctured-agent-session-execution-options.ts | 42 + ...ession-failed-create-owner-verdict.test.ts | 20 + ...session-failed-create-sink-release.test.ts | 147 --- ...ctured-agent-session-forget-status.test.ts | 1 + ...tured-agent-session-grouped-prompt.test.ts | 283 ------ .../structured-agent-session-handing-over.ts | 21 +- .../structured-agent-session-handover.ts | 229 +++++ ...d-agent-session-held-start.test-support.ts | 58 +- .../structured-agent-session-host-delivery.ts | 39 +- ...nt-session-host-held-child-renewal.test.ts | 116 --- ...ured-agent-session-host-mutation-wiring.ts | 4 +- ...structured-agent-session-host-mutations.ts | 61 +- ...ctured-agent-session-host-runtime-state.ts | 4 +- ...ed-agent-session-host-test-abandon.test.ts | 126 --- ...uctured-agent-session-host-test-harness.ts | 4 + .../structured-agent-session-host-types.ts | 2 +- .../structured-agent-session-host.test.ts | 38 +- .../structured-agent-session-host.ts | 19 +- ...tructured-agent-session-idle-sweep.test.ts | 17 + ...gent-session-instant-start.test-support.ts | 23 +- ...ured-agent-session-late-settlement.test.ts | 5 +- ...t-session-launch-send-after-create.test.ts | 4 +- ...gent-session-legacy-handoff-record.test.ts | 5 +- .../structured-agent-session-logger.test.ts | 24 +- ...ssion-main-agent-working-agreement.test.ts | 258 ----- ...ured-agent-session-message-choices.test.ts | 155 +++ ...tructured-agent-session-message-options.ts | 103 +- ...red-agent-session-mutation-context.test.ts | 4 +- ...ructured-agent-session-mutation-context.ts | 9 +- ...structured-agent-session-mutation-plans.ts | 15 +- ...tructured-agent-session-operation-child.ts | 95 ++ ...nt-session-operation-start-refusal.test.ts | 15 +- ...ctured-agent-session-option-restoration.ts | 59 -- ...ed-agent-session-option-settlement.test.ts | 240 ----- ...uctured-agent-session-options-read.test.ts | 8 +- .../structured-agent-session-options-read.ts | 42 +- ...tured-agent-session-outgrown-child.test.ts | 20 - ...structured-agent-session-outgrown-child.ts | 15 +- ...ed-agent-session-owed-work-release.test.ts | 6 +- ...ent-session-pre-spawn-first-answer.test.ts | 251 ----- ...nt-session-processless-reservation.test.ts | 43 +- ...ent-session-prompt-answer-receipts.test.ts | 93 +- ...gent-session-provider-child-record.test.ts | 909 ----------------- ...ed-agent-session-provider-open-contract.ts | 2 +- ...ctured-agent-session-provider-open.test.ts | 1 - .../structured-agent-session-provider-open.ts | 19 +- ...red-agent-session-provider-restore.test.ts | 111 --- ...t-session-provider-started-catalog.test.ts | 114 --- ...red-agent-session-provider-started.test.ts | 269 ----- ...ructured-agent-session-provider-started.ts | 103 +- ...ructured-agent-session-provider-support.ts | 2 +- ...ueued-message-rig-provider.test-fixture.ts | 18 +- ...session-queued-message-rig.test-fixture.ts | 24 +- ...tructured-agent-session-queued-messages.ts | 16 +- ...gent-session-ready-operation-order.test.ts | 89 ++ ...tructured-agent-session-ready-operation.ts | 180 ++-- ...tured-agent-session-recovery-exits.test.ts | 16 +- ...tured-agent-session-recovery-resolution.ts | 17 + ...ctured-agent-session-refusal-retry.test.ts | 375 ------- ...gent-session-registered-definition.test.ts | 199 ---- ...uctured-agent-session-repeat-press.test.ts | 262 ----- .../structured-agent-session-rest-test-rig.ts | 14 +- ...tured-agent-session-restart-resume-host.ts | 1 - ...session-restart-status-publication.test.ts | 162 --- ...d-agent-session-restart-undrivable.test.ts | 25 +- ...ed-agent-session-restart-witnesses.test.ts | 1 - ...uctured-agent-session-restart-witnesses.ts | 2 - ...tured-agent-session-rewind-at-rest.test.ts | 4 +- ...red-agent-session-rewind-live-send.test.ts | 4 +- .../structured-agent-session-rewind.test.ts | 4 +- ...uctured-agent-session-send-options.test.ts | 130 +-- ...red-agent-session-send-preparation.test.ts | 700 ------------- ...ructured-agent-session-send-preparation.ts | 2 +- ...session-send-restarts-failed-start.test.ts | 24 +- .../structured-agent-session-send.test.ts | 63 +- ...agent-session-settled-attach-retry.test.ts | 421 -------- ...ured-agent-session-start-admission.test.ts | 507 ++++++++++ ...uctured-agent-session-start-eligibility.ts | 85 ++ ...uctured-agent-session-start-failure-row.ts | 82 +- ...agent-session-start-failure-writer.test.ts | 8 +- .../structured-agent-session-start-flight.ts | 104 ++ ...-agent-session-startup-attempt-contract.ts | 25 +- ...agent-session-startup-attempt-host.test.ts | 149 ++- ...sion-startup-attempt-registrations.test.ts | 37 +- ...ured-agent-session-startup-attempt.test.ts | 249 ----- ...tructured-agent-session-startup-attempt.ts | 147 ++- ...agent-session-startup-failure-exit.test.ts | 10 +- ...tructured-agent-session-stop-event.test.ts | 25 +- ...gent-session-stop-note-opened-turn.test.ts | 3 + ...t-session-stop-note-withdrawn-send.test.ts | 3 + ...ctured-agent-session-stop-receipts.test.ts | 2 + ...red-agent-session-surface-lifetime.test.ts | 936 ------------------ .../structured-agent-session-thread-goal.ts | 12 +- .../structured-agent-session-turns-options.ts | 37 - .../structured-agent-session-turns.test.ts | 2 + .../structured-agent-session-turns.ts | 161 +-- ...ured-agent-session-unexpected-exit.test.ts | 30 + ...t-session-wedged-profile-migration.test.ts | 14 +- ...tured-agent-session-working-at-teardown.ts | 12 +- ...t-session-workspace-sleep-lifetime.test.ts | 146 ++- ...ured-agent-session-workspace-sleep-stop.ts | 75 +- .../structured-conversation-command.test.ts | 14 +- ...structured-conversation-compaction.test.ts | 5 +- .../structured-rewind-recovery.ts | 131 ++- ...native-chat-inline-visuals-setting.test.ts | 4 +- ...ructured-chat-naming-command-first.test.ts | 151 --- ...structured-chat-naming-status-hook.test.ts | 158 --- .../structured-chat-naming.test.ts | 35 + src/main/pi/pi-scripted-child.test-fixture.ts | 267 +++++ src/main/pi/rpc-configured-default.test.ts | 402 -------- src/main/pi/rpc-message-dispatch.ts | 112 +++ src/main/pi/rpc-options.test.ts | 4 +- src/main/pi/rpc-options.ts | 31 +- src/main/pi/rpc-runtime-registration.ts | 12 +- .../pi/rpc-session-adapter.test-fixture.ts | 224 +++++ src/main/pi/rpc-session-adapter.test.ts | 320 ++---- src/main/pi/rpc-session-adapter.ts | 89 +- src/main/pi/rpc-session-startup.test.ts | 156 +++ src/main/pi/rpc-session-startup.ts | 147 +++ src/main/pi/rpc-session.ts | 117 ++- .../managed-provider-process-output.test.ts | 56 -- .../agent-session-record-options.test.ts | 187 ---- .../runtime/agent-session-record-store.ts | 10 +- ...structured-fake-connection-test-fixture.ts | 3 +- ...ude-structured-session-integration.test.ts | 3 +- ...startup-unanswered-control-request.test.ts | 10 +- .../rpc/methods/agent-launch-at-rest.test.ts | 87 ++ .../rpc/methods/agent-launch-surfaces.ts | 10 +- ...ured-agent-session-adoption-replay.test.ts | 4 + ...tured-agent-session-create-message.test.ts | 18 + .../structured-agent-session-create.ts | 11 +- .../structured-agent-session-hold.test.ts | 237 ----- ...uctured-agent-session-options-read.test.ts | 70 -- .../structured-agent-session-options-read.ts | 27 +- .../structured-chat-tab-table.test-fixture.ts | 4 + ...ed-agent-runtime-registrations-acp.test.ts | 3 + .../structured-agent-runtime-registrations.ts | 10 +- ...tured-agent-scripted-child.test-fixture.ts | 33 + ...gent-session-adapter-start-failure.test.ts | 128 +++ ...nt-session-codex-opening-send-hold.test.ts | 21 +- ...agent-session-codex-scripted-child.test.ts | 187 ++++ ...agent-session-codex-start-deadline.test.ts | 121 +++ ...-session-codex-turn-end-settlement.test.ts | 75 +- ...ured-agent-session-failure-logging.test.ts | 343 ------- ...d-agent-session-integration-replay.test.ts | 12 +- ...ructured-agent-session-integration.test.ts | 81 +- ...-agent-session-pi-signed-out-start.test.ts | 113 +++ ...tructured-agent-session-pi-startup.test.ts | 177 ++++ ...provider-operations-while-starting.test.ts | 236 +++++ ...tured-agent-session-transport-overrides.ts | 9 + .../structured-child-records-switch.test.ts | 13 +- ...ed-claude-scripted-runtime-test-support.ts | 3 +- ...tructured-codex-child-work-runtime.test.ts | 3 + src/shared/agent-session-execution-options.ts | 30 + ...agent-session-journal-submission-schema.ts | 3 +- src/shared/agent-session-journal-types.ts | 7 +- src/shared/agent-session-options-result.ts | 54 + src/shared/agent-session-wire.ts | 54 +- .../structured-agent-session-params.ts | 4 +- ...ctured-agent-session-send-mutation.spec.ts | 36 + ...ss-version-agent-session-send.unit.test.ts | 38 + ...ss-version-agent-session-wire.unit.test.ts | 8 +- ...oss-version-registered-agents.unit.test.ts | 27 +- .../cross-version-stop-note-test-rig.ts | 45 +- .../structured-agent-session-host-fixture.ts | 1 + ...ructured-agent-session-surface-manifest.ts | 6 +- ...-chat-owner-status-activation.unit.test.ts | 2 +- 348 files changed, 10422 insertions(+), 14043 deletions(-) create mode 100644 src/main/acp/acp-message-execution-options.ts create mode 100644 src/main/acp/acp-scripted-child-runtime.test.ts create mode 100644 src/main/acp/acp-scripted-child.test-fixture.ts create mode 100644 src/main/acp/acp-scripted-connection.test-fixture.ts create mode 100644 src/main/acp/acp-structured-child-lifecycle.ts create mode 100644 src/main/acp/acp-structured-child.ts create mode 100644 src/main/acp/acp-structured-handshake.ts create mode 100644 src/main/acp/acp-structured-host-start-failure.test.ts create mode 100644 src/main/acp/acp-structured-publish-at-spawn.test.ts create mode 100644 src/main/claude/claude-structured-message-options.test.ts create mode 100644 src/main/claude/claude-structured-message-options.ts delete mode 100644 src/main/claude/claude-structured-start-fast-mode.ts create mode 100644 src/main/codex/__fixtures__/codex-connection-app-server.ts create mode 100644 src/main/codex/codex-scripted-child.test-fixture.ts create mode 100644 src/main/codex/codex-structured-message-options.test.ts create mode 100644 src/main/codex/codex-structured-message-options.ts create mode 100644 src/main/codex/codex-structured-option-session.test-fixture.ts delete mode 100644 src/main/codex/codex-structured-question-order.test.ts create mode 100644 src/main/codex/codex-structured-session-start.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-lock.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts delete mode 100644 src/main/native-chat/structured-chat-naming-command-first.test.ts delete mode 100644 src/main/native-chat/structured-chat-naming-status-hook.test.ts create mode 100644 src/main/pi/pi-scripted-child.test-fixture.ts delete mode 100644 src/main/pi/rpc-configured-default.test.ts create mode 100644 src/main/pi/rpc-message-dispatch.ts create mode 100644 src/main/pi/rpc-session-adapter.test-fixture.ts create mode 100644 src/main/pi/rpc-session-startup.test.ts create mode 100644 src/main/pi/rpc-session-startup.ts delete mode 100644 src/main/provider-process/managed-provider-process-output.test.ts delete mode 100644 src/main/runtime/agent-session-record-options.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts delete mode 100644 src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts create mode 100644 src/main/runtime/structured-agent-scripted-child.test-fixture.ts create mode 100644 src/main/runtime/structured-agent-session-adapter-start-failure.test.ts create mode 100644 src/main/runtime/structured-agent-session-codex-scripted-child.test.ts create mode 100644 src/main/runtime/structured-agent-session-codex-start-deadline.test.ts delete mode 100644 src/main/runtime/structured-agent-session-failure-logging.test.ts create mode 100644 src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts create mode 100644 src/main/runtime/structured-agent-session-pi-startup.test.ts create mode 100644 src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts create mode 100644 src/shared/agent-session-execution-options.ts create mode 100644 src/shared/agent-session-options-result.ts 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,