From 1a07fc6d8cf9ce1bcc9f431fe4caff79db93ed32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:00:35 -0700 Subject: [PATCH 01/10] Every agent opens its chat at launch, and one Stop rule (rebuilt on main) Rebuilds #26561 on main now that #26416 is squash-merged. Same change as before, re-applied onto what main has since: - Every adapter answers acquire at spawn and reports `started` on protocol readiness with its real handle (Claude initialize, Codex thread, ACP session new/load plus restore, Pi session file plus restore). No bridge, no per-adapter phase or Stop opt-outs. - Picks made while starting are intents applied model then effort before the start is accepted; rewind, goal, rewind recovery and compact wait for a proven start outside the session queue. - One Stop rule: Stop ends the session for every agent (Codex: interrupt, up to 2 s for the turn to end, then close). - Orchestration worker starts share one deadline; a send's answer copies its row; a re-attach during start keeps the start wait. Integrated with main: - ACP: the subagent-stop probe, child-work delivery and new/loaded origin move into the handshake; the sign-in rule (#26544) classifies a failed handshake; acpAgentName stays in acp-structured-agent-definitions. - Codex: the session journal translator and account kind (#26407) reach the start step. - Pi: the start choice (#26821) and the signed-out verdict (#26743) are read in Pi's start, so both exist when `started` is reported; commands follow. - openWithAgent keeps main's pre-agent check and replay rule. - A report with no model drops the saved one, as #26587 decided. - The renderer re-reads a start's sign-in verdict once the start is proven. --- .../scripts/vitest-sqlite-runtime-files.mjs | 7 +- src/main/acp/acp-prompt-content.ts | 2 +- .../acp/acp-scripted-child-runtime.test.ts | 88 +++++ .../acp/acp-scripted-child.test-fixture.ts | 142 ++++++++ .../acp-scripted-connection.test-fixture.ts | 93 +++++ src/main/acp/acp-structured-acquire.ts | 239 ++++++------- .../acp-structured-adapter.test-support.ts | 158 ++++----- src/main/acp/acp-structured-auth.test.ts | 8 +- .../acp/acp-structured-broken-stream.test.ts | 8 +- .../acp/acp-structured-child-lifecycle.ts | 155 +++++++++ src/main/acp/acp-structured-child-stop.ts | 8 +- src/main/acp/acp-structured-child.ts | 103 ++++++ src/main/acp/acp-structured-handshake.ts | 174 ++++++++++ ...structured-host-configured-default.test.ts | 4 + .../acp/acp-structured-host-lifecycle.test.ts | 45 +-- ...acp-structured-host-restore-failed.test.ts | 49 ++- .../acp-structured-host-start-aborts.test.ts | 3 +- .../acp-structured-host-start-failure.test.ts | 31 ++ .../acp/acp-structured-host.test-support.ts | 9 +- .../acp/acp-structured-missing-model.test.ts | 23 +- src/main/acp/acp-structured-options.ts | 6 +- .../acp-structured-publish-at-spawn.test.ts | 170 +++++++++ ...ructured-session-adapter-lifecycle.test.ts | 93 ++--- ...-structured-session-adapter-resume.test.ts | 23 +- ...tructured-session-adapter-teardown.test.ts | 2 +- .../acp-structured-session-adapter.test.ts | 40 +-- .../acp/acp-structured-session-adapter.ts | 199 ++++------- src/main/acp/acp-structured-session.ts | 3 +- src/main/acp/acp-structured-sign-in.test.ts | 20 +- src/main/acp/acp-structured-steer.test.ts | 4 +- .../claude-scripted-child.test-fixture.ts | 164 +++++++++ .../claude-structured-prompt-ownership.ts | 4 +- .../claude/claude-structured-real-cli.test.ts | 2 +- .../claude-structured-resume-point.test.ts | 2 +- .../claude-structured-session-acquisition.ts | 2 +- .../claude-structured-session-adapter.test.ts | 185 ++-------- .../claude-structured-session-adapter.ts | 15 - .../claude-structured-session-prompts.test.ts | 159 +++++++++ ...claude-structured-session-startup-state.ts | 17 - .../claude-structured-session-startup.test.ts | 33 +- src/main/codex/codex-app-server-connection.ts | 34 +- ...codex-app-server-spawn-publication.test.ts | 40 +++ .../codex/codex-collab-call-frames-fixture.ts | 9 +- .../codex-default-mode-subagents.test.ts | 9 +- .../codex/codex-provider-retry-row.test.ts | 9 +- src/main/codex/codex-request-end-wait.test.ts | 91 +++++ src/main/codex/codex-request-end-wait.ts | 56 +++ .../codex-scripted-child.test-fixture.ts | 156 +++++++++ .../codex-structured-background-catalog.ts | 22 +- ...dex-structured-child-work-producer.test.ts | 9 +- .../codex-structured-dispatch-test-support.ts | 3 +- .../codex/codex-structured-fast-mode.test.ts | 22 +- .../codex-structured-journal-translation.ts | 3 + ...x-structured-live-provider-process.test.ts | 9 +- .../codex-structured-prompt-ownership.test.ts | 39 ++- .../codex-structured-publish-at-spawn.test.ts | 286 ++++++++++++++++ .../codex/codex-structured-session-acquire.ts | 139 ++------ ...odex-structured-session-adapter-fixture.ts | 71 +++- ...ructured-session-adapter-lifecycle.test.ts | 27 +- .../codex-structured-session-adapter.test.ts | 139 +++++--- .../codex/codex-structured-session-adapter.ts | 40 +-- ...tructured-session-background-tasks.test.ts | 9 +- .../codex-structured-session-cancel.test.ts | 3 +- .../codex-structured-session-close.test.ts | 23 +- .../codex/codex-structured-session-close.ts | 36 +- ...odex-structured-session-exit-blame.test.ts | 14 +- .../codex-structured-session-options.test.ts | 26 +- .../codex/codex-structured-session-options.ts | 16 + .../codex-structured-session-prompts.test.ts | 15 +- .../codex-structured-session-shutdown.test.ts | 7 +- .../codex/codex-structured-session-start.ts | 203 +++++++++++ .../codex/codex-structured-session-state.ts | 19 +- .../codex-structured-session-teardown.ts | 12 +- .../codex/codex-structured-starting-child.ts | 55 +++ .../codex/codex-structured-turn-steer.test.ts | 6 +- .../codex/codex-structured-visuals.test.ts | 6 +- src/main/jsonl-rpc/agent-connection.ts | 13 +- ...-agent-session-accept-then-deliver.test.ts | 47 +-- ...-agent-session-acquisition-options.test.ts | 244 +------------ .../structured-agent-session-acquisition.ts | 41 +-- ...tured-agent-session-adapter-router.test.ts | 37 +- ...structured-agent-session-adapter-router.ts | 23 -- .../structured-agent-session-adapter-stop.ts | 15 +- .../structured-agent-session-adapter.ts | 42 +-- ...ured-agent-session-append-delivery.test.ts | 3 +- .../structured-agent-session-attach-flow.ts | 31 +- ...ured-agent-session-attach-orchestration.ts | 77 +++-- .../structured-agent-session-chat-stop.ts | 7 +- .../structured-agent-session-child-close.ts | 4 +- ...-agent-session-claude-option-queue.test.ts | 73 ++-- ...-agent-session-codex-stop-note-end.test.ts | 8 +- ...tured-agent-session-codex-stop-row.test.ts | 180 +++++----- ...ured-agent-session-command-readers.test.ts | 2 +- ...d-agent-session-conversation-close.test.ts | 5 +- ...red-agent-session-conversation-lifetime.ts | 51 +-- ...ed-agent-session-conversation-stop.test.ts | 316 ++++++++--------- ...ured-agent-session-crash-mid-start.test.ts | 90 ++++- .../structured-agent-session-create.test.ts | 10 +- ...structured-agent-session-event-recovery.ts | 7 +- ...d-agent-session-held-start.test-support.ts | 62 ++++ .../structured-agent-session-host-delivery.ts | 2 +- ...structured-agent-session-host-mutations.ts | 74 ++-- ...ctured-agent-session-host-runtime-state.ts | 11 +- ...uctured-agent-session-host-test-harness.ts | 24 +- .../structured-agent-session-host-types.ts | 7 +- .../structured-agent-session-host.test.ts | 9 +- .../structured-agent-session-host.ts | 6 +- ...gent-session-instant-start.test-support.ts | 35 ++ ...ured-agent-session-late-settlement.test.ts | 50 +-- ...gent-session-legacy-handoff-record.test.ts | 22 +- ...ssion-main-agent-working-agreement.test.ts | 54 +-- ...structured-agent-session-mutation-plans.ts | 2 +- ...ctured-agent-session-option-restoration.ts | 20 +- ...ed-agent-session-option-settlement.test.ts | 16 +- .../structured-agent-session-options-read.ts | 3 +- ...gent-session-provider-child-record.test.ts | 26 +- ...-session-provider-child-start-wait.test.ts | 51 +++ ...structured-agent-session-provider-child.ts | 69 ++++ ...tured-agent-session-provider-start-hold.ts | 90 +++++ ...t-session-provider-started-catalog.test.ts | 43 ++- ...ructured-agent-session-provider-started.ts | 157 ++++++++- ...ed-agent-session-queued-card-holds.test.ts | 14 +- ...tured-agent-session-queued-compact.test.ts | 3 +- ...ueued-message-rig-provider.test-fixture.ts | 48 ++- ...session-queued-message-rig.test-fixture.ts | 2 +- ...ured-agent-session-queued-messages.test.ts | 3 +- ...ed-agent-session-queued-pause-lift.test.ts | 46 ++- ...ured-agent-session-queued-stop-row.test.ts | 36 +- ...ctured-agent-session-refusal-retry.test.ts | 56 +-- ...uctured-agent-session-repeat-press.test.ts | 46 +-- ...ctured-agent-session-repeated-stop.test.ts | 16 +- .../structured-agent-session-rest-test-rig.ts | 12 +- ...gent-session-restart-context-clear.test.ts | 4 +- ...tured-agent-session-restart-resume-host.ts | 1 - ...tured-agent-session-restart-resume.test.ts | 90 ++++- ...session-restart-status-publication.test.ts | 5 +- ...ed-agent-session-restart-witnesses.test.ts | 1 - ...uctured-agent-session-restart-witnesses.ts | 2 - ...tured-agent-session-rewind-at-rest.test.ts | 66 ++-- ...red-agent-session-rewind-live-send.test.ts | 66 ++-- ...t-session-rewind-stop-note.test-fixture.ts | 2 + .../structured-agent-session-rewind.test.ts | 144 ++++---- .../structured-agent-session-rewind.ts | 34 +- ...red-agent-session-send-preparation.test.ts | 25 +- ...ructured-agent-session-send-preparation.ts | 38 ++- ...session-send-restarts-failed-start.test.ts | 3 +- ...agent-session-settled-attach-retry.test.ts | 20 +- ...agent-session-start-failure-writer.test.ts | 3 +- ...agent-session-startup-attempt-host.test.ts | 31 +- ...sion-startup-attempt-registrations.test.ts | 192 ----------- ...ured-agent-session-startup-attempt.test.ts | 30 +- ...tructured-agent-session-startup-attempt.ts | 20 +- ...structured-agent-session-startup-intent.ts | 137 ++++++++ ...ctured-agent-session-startup-picks.test.ts | 236 +++++++++++++ ...agent-session-steer-while-stopping.test.ts | 102 ++++-- ...d-agent-session-stop-event-binding.test.ts | 74 ++-- ...d-agent-session-stop-event-entries.test.ts | 97 ++++-- ...d-agent-session-stop-event-restart.test.ts | 81 +++-- ...tructured-agent-session-stop-event.test.ts | 87 +++-- ...red-agent-session-stop-never-waits.test.ts | 19 +- ...-agent-session-stop-settle-binding.test.ts | 15 +- ...structured-agent-session-stop-wind-down.ts | 29 +- .../structured-agent-session-stopping.test.ts | 120 ++++--- ...red-agent-session-surface-lifetime.test.ts | 22 +- .../structured-agent-session-thread-goal.ts | 31 +- .../structured-agent-session-turns-cancel.ts | 65 +++- .../structured-agent-session-turns.ts | 5 +- ...ured-agent-session-unrun-send-hold.test.ts | 3 - ...tured-agent-session-working-at-teardown.ts | 7 +- .../structured-conversation-command.test.ts | 14 + .../structured-conversation-command.ts | 12 + ...structured-conversation-compaction.test.ts | 50 +-- .../structured-conversation-compaction.ts | 13 + .../structured-rewind-refusal.ts | 19 ++ ...native-chat-inline-visuals-setting.test.ts | 4 +- src/main/pi/pi-scripted-child.test-fixture.ts | 249 ++++++++++++++ src/main/pi/rpc-configured-default.test.ts | 7 +- src/main/pi/rpc-runtime-registration.ts | 18 +- .../pi/rpc-session-adapter.test-fixture.ts | 221 ++++++++++++ src/main/pi/rpc-session-adapter.test.ts | 222 ++---------- src/main/pi/rpc-session-adapter.ts | 22 +- src/main/pi/rpc-session-startup.test.ts | 248 ++++++++++++++ src/main/pi/rpc-session-startup.ts | 165 +++++++++ src/main/pi/rpc-session.ts | 108 +++--- ...-session-acquisition-failure-settlement.ts | 3 +- .../agent-session-lease-transitions.ts | 23 +- .../agent-session-record-options.test.ts | 109 +++--- .../agent-session-record-store.test.ts | 5 +- .../runtime/agent-session-record-store.ts | 14 +- ...startup-unanswered-control-request.test.ts | 1 - .../orchestration/send-agent-turn.test.ts | 11 + .../runtime/orchestration/send-agent-turn.ts | 10 +- ...orchestration-structured-worker-session.ts | 9 +- ...n-structured-worker-start-deadline.test.ts | 110 ++++++ .../deliver-worker-dispatch-preamble.ts | 5 +- .../worker/local-worker-start.ts | 13 +- .../worker-start-readiness-settlement.test.ts | 1 + .../worker-start-readiness-settlement.ts | 6 +- ...gent-registration-lifecycle-matrix.test.ts | 301 ++++++++++++++++ .../structured-agent-runtime-registrations.ts | 17 +- ...tured-agent-scripted-child.test-fixture.ts | 33 ++ ...d-agent-session-codex-compact-exit.test.ts | 7 +- ...gent-session-codex-interrupt-order.test.ts | 1 + ...nt-session-codex-opening-send-hold.test.ts | 1 + ...agent-session-codex-scripted-child.test.ts | 158 +++++++++ ...t-session-codex-stopped-send-order.test.ts | 9 +- ...-session-codex-turn-end-settlement.test.ts | 26 +- ...ructured-agent-session-integration.test.ts | 24 +- ...tructured-agent-session-pi-startup.test.ts | 188 ++++++++++ ...provider-operations-while-starting.test.ts | 323 ++++++++++++++++++ .../structured-agent-session-runtime.ts | 13 +- ...tured-agent-session-transport-overrides.ts | 24 ++ .../structured-claude-pending-rewind.test.ts | 3 +- .../NativeChatStructuredSession.tsx | 3 +- .../use-structured-agent-session.ts | 2 +- src/shared/agent-session-record.ts | 6 +- ...ss-version-agent-session-wire.unit.test.ts | 236 ++++++++----- .../cross-version-stop-note-test-rig.ts | 75 ++-- .../structured-agent-session-host-fixture.ts | 41 +++ ...-chat-owner-status-activation.unit.test.ts | 51 +-- 220 files changed, 8380 insertions(+), 3357 deletions(-) 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-scripted-child.test-fixture.ts create mode 100644 src/main/claude/claude-structured-session-prompts.test.ts create mode 100644 src/main/codex/codex-app-server-spawn-publication.test.ts create mode 100644 src/main/codex/codex-request-end-wait.test.ts create mode 100644 src/main/codex/codex-request-end-wait.ts create mode 100644 src/main/codex/codex-scripted-child.test-fixture.ts create mode 100644 src/main/codex/codex-structured-publish-at-spawn.test.ts create mode 100644 src/main/codex/codex-structured-session-start.ts create mode 100644 src/main/codex/codex-structured-starting-child.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-start-wait.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-intent.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts create mode 100644 src/main/pi/pi-scripted-child.test-fixture.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 create mode 100644 src/main/runtime/rpc/methods/orchestration-structured-worker-start-deadline.test.ts create mode 100644 src/main/runtime/structured-agent-registration-lifecycle-matrix.test.ts create mode 100644 src/main/runtime/structured-agent-scripted-child.test-fixture.ts create mode 100644 src/main/runtime/structured-agent-session-codex-scripted-child.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/main/runtime/structured-agent-session-transport-overrides.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index d839482b65c9..ad0bb557a50e 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -22,6 +22,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', @@ -158,7 +159,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ '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-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-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', @@ -225,12 +226,16 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/runtime-orchestration-sender-names.test.ts', 'src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts', 'src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts', + 'src/main/runtime/structured-agent-registration-lifecycle-matrix.test.ts', '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/structured-agent-session-codex-scripted-child.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', 'src/main/runtime/structured-agent-session-mobile-restore.test.ts', + 'src/main/runtime/structured-agent-session-pi-startup.test.ts', + 'src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts', 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', diff --git a/src/main/acp/acp-prompt-content.ts b/src/main/acp/acp-prompt-content.ts index 2d1c6e336b1d..708cee6da7bf 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 type { ContentBlock } from './generated/acp-protocol.generated' 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 000000000000..7f593c2613e2 --- /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 000000000000..3c1e47374c36 --- /dev/null +++ b/src/main/acp/acp-scripted-child.test-fixture.ts @@ -0,0 +1,142 @@ +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`' + ) + ) + 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 000000000000..a5d2dc29953f --- /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-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 3f8e9555e9f8..e766714d4548 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,17 +1,16 @@ // 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` only for an -// agent that cannot load) 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 child reports `started` 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, - type StructuredAgentSessionAcquireInput + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' import { @@ -19,21 +18,18 @@ import { PROVIDER_SPAWN_TOKEN_ENV } from '../provider-process/provider-spawned-process-identity' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' -import { AcpAgentError } from './acp-errors' -import { acpAuthenticationRequired, acpSignInRequiredRefusal } from './acp-turn-failures' -import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' import { - ACP_REOPEN_FAILED, - acpReopenTakeover, - acpSessionNotRestoredRow -} from './acp-session-reopen-failure' + assertAcpStartingSession, + type AcpStructuredChild, + type AcpStartingSession +} from './acp-structured-child' +import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' 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 { probeAcpChildStop } from './acp-structured-child-stop' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' -import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { AcpStructuredOptions } from './acp-structured-options' import { AcpStructuredPrompts } from './acp-structured-prompts' import { asReattachHistory, @@ -41,13 +37,12 @@ import { type AcpStructuredSession } 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 @@ -58,13 +53,18 @@ export async function acquireAcpStructuredSession(input: { /** 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 + onReady: (session: AcpStructuredSession, event: StructuredAgentSessionStartedEvent) => void onSettled: AcpStructuredTurnsDeps['settle'] forceClose: (sessionId: string) => void -}): Promise<{ acquisition: AgentSessionAcquisition; session: AcpStructuredSession }> { +}): Promise<{ + acquisition: AgentSessionAcquisition + session: AcpStartingSession + initialize: () => Promise +}> { const { acquire, deps, generation } = input const { spec } = deps const sessionId = acquire.identity.sessionId @@ -88,7 +88,7 @@ export async function acquireAcpStructuredSession(input: { throw new AgentSessionPreSpawnError(error) }) 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, @@ -99,9 +99,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, + () => session?.phase === 'ready' && session.turns.acceptsRequests, () => - session !== null && + session?.phase === 'ready' && (session.turns.acceptsRequests || (!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null)) ) @@ -128,7 +128,7 @@ export async function acquireAcpStructuredSession(input: { clientInfo: { name: 'orca', version: '1' }, ...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}), onPermission: (request, context) => { - if (!session?.turns.acceptsRequests) { + if (session?.phase !== 'ready' || !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' } } @@ -173,11 +173,28 @@ 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(() => { + const optionRevision = acquire.optionRevision?.() ?? 0 + if (slot.lane) { routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) - ) + } + if ( + session?.phase === 'ready' && + !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, + restoreSkippedOptions: [], + optionRevision + }) + } + }) ) const identity = providerSpawnedProcessIdentity( acquire, @@ -185,7 +202,6 @@ export async function acquireAcpStructuredSession(input: { deps.readProcessStartTime ) /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ - let subagentStopSupported = false const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { const lane = new AcpStructuredLane({ sink, @@ -196,10 +212,14 @@ export async function acquireAcpStructuredSession(input: { providerSessionId, dialect: spec.dialect, now, - canStopSubagents: () => subagentStopSupported, + canStopSubagents: () => session?.phase === 'ready' && session.subagentStopSupported === true, ...acpLaneChildWorkDelivery(deps, sessionId), logger: deps.logger ?? createStructuredAgentSessionLogger(), - onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId), + onInputAccepted: (clientMessageId) => { + if (session?.phase === 'ready') { + session.turns.accept(clientMessageId) + } + }, onFailed: () => input.forceClose(sessionId) }) slot.lane = lane @@ -220,114 +240,55 @@ export async function acquireAcpStructuredSession(input: { ) } await identity.onSpawned(pid) - const agentName = acpAgentName(spec.agent) - 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 auth = authMethodId === undefined ? {} : { authMethodId } - 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, - ...auth - }) - 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. - } + const process = await identity.read(pid) + const starting: AcpStartingSession = { + phase: 'starting', + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + spec, + connection, + closeRequested: false, + journalClosed: null, + ended: false, + exitObservedAt: null, + startupAnswered: false, + dispose: () => { + slot.lane?.dispose() + early.length = 0 } - if (!started || !liveLane) { - liveLane?.dispose() - slot.lane = null - started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) - liveLane = makeLane(started.sessionId) - } - subagentStopSupported = await probeAcpChildStop(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 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, - subagentStopSupported, + } + session = starting + assertAcpStartingSession(starting, input.abandoned) + const initialize = () => + initializeAcpStructuredSession({ + acquire, + deps, + generation, + launch, connection, - lane: liveLane, - prompts, options, - turns: new AcpStructuredTurns({ - connection, - withdrawRequests: () => prompts.withdrawAll(), - lane: liveLane, - agentName, - now, - settle: input.onSettled - }), - restoreSkipped, - closeRequested: false, - journalClosed: null, - ended: false, - exitObservedAt: null - } - const reading = liveLane - const unbind = events?.bindReadingControl?.({ - pauseReading: () => connection.pauseReading(), - resumeReading: () => { - connection.resumeReading() - reading.retry() + prompts, + starting, + abandoned: input.abandoned, + lanes: { + create: makeLane, + clear: () => { + slot.lane = null + }, + finishLoad: () => { + slot.reattaching = false + } + }, + onSettled: input.onSettled, + onReady: (ready, event) => { + session = ready + input.onReady(ready, event) } }) - 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 + return { + acquisition: { process, acquisitionGeneration: generation }, + session: starting, + initialize } } diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index ca3e3bd7f66e..2a63b40f5501 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -1,29 +1,27 @@ +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 { vi } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -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 { + StructuredAgentSessionLifecycleEvent, + StructuredAgentSessionStartedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' 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' export const GROK = acpLaunchSpecFor('grok')! export const PROVIDER_SESSION = 'acp-session-1' -export const PID = 4242 export const GROK_CONFIG_OPTIONS = [ { @@ -50,102 +48,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(): StructuredAgentSessionStartedEvent settled: Parameters>[0][] acquire(options?: { fence?: number onSpawned?: () => Promise signal?: AbortSignal + waitForStart?: boolean + options?: Readonly> + optionRevision?: () => number }): ReturnType /** Frames Orca wrote to the agent with this method. */ sent(method: string): FakeFrame[] @@ -169,6 +87,7 @@ export async function openAcpAdapterRig( const spawned: string[] = [] const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] + const startListeners = new Set<() => void>() const spec = options.spec ?? GROK const adapter = new AcpStructuredSessionAdapter({ spec, @@ -208,11 +127,17 @@ export async function openAcpAdapterRig( return child }, readProcessStartTime: async () => 1_700_000_000_000, - 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 { @@ -226,9 +151,20 @@ export async function openAcpAdapterRig( }, spawned, lifecycle, + get ended() { + return lifecycle.filter((event) => event.type === 'ended') + }, + started: () => { + const event = lifecycle.findLast((entry) => entry.type === 'started') + if (event?.type !== 'started') { + throw new Error('ACP session has not started') + } + return event + }, settled, - acquire: (acquireOptions = {}) => - adapter.acquire({ + acquire: async (acquireOptions = {}) => { + const beginning = lifecycle.length + const acquisition = await adapter.acquire({ identity: { sessionId: SESSION, workspaceId: 'workspace-1', @@ -239,12 +175,36 @@ export async function openAcpAdapterRig( fence: acquireOptions.fence ?? 1, spawnToken: 'spawn-1', events: rig.eventSink, + ...(acquireOptions.options ? { options: acquireOptions.options } : {}), + ...(acquireOptions.optionRevision ? { optionRevision: acquireOptions.optionRevision } : {}), ...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}), onSpawned: async () => { spawned.push('onSpawned') await acquireOptions.onSpawned?.() } - }), + }) + if (acquireOptions.waitForStart !== false) { + await new Promise((resolve) => { + const inspect = () => { + if ( + lifecycle + .slice(beginning) + .some( + (event) => + event.type !== 'options-reported' && + event.acquisitionGeneration === acquisition.acquisitionGeneration + ) + ) { + startListeners.delete(inspect) + resolve() + } + } + startListeners.add(inspect) + inspect() + }) + } + return acquisition + }, sent: (method) => frames().filter((frame) => frame.method === method), frame: (method, index = 0) => waitFor(() => { diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts index 92f27513cd51..e47f77ef436c 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.ended.length ? rig.ended[0] : rig.started(), methods } } describe('Grok sign-in on its own machine', () => { @@ -92,8 +91,7 @@ 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' }) + expect(acquired).toMatchObject({ type: 'ended', failure: { kind: 'notSignedIn' } }) }) it('signs in the same way when it reopens a saved session', async () => { diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts index 5b2d6bf1e275..0f2bab38d3fe 100644 --- a/src/main/acp/acp-structured-broken-stream.test.ts +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -113,11 +113,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) }) @@ -153,8 +153,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 000000000000..47945cd70139 --- /dev/null +++ b/src/main/acp/acp-structured-child-lifecycle.ts @@ -0,0 +1,155 @@ +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts' +import { AcpConnectionClosedError } from './acp-errors' +import { + acpStartupFailure, + endAcpStartingSession, + type AcpStartingSession, + type AcpStructuredChild +} from './acp-structured-child' +import { waitForAcpExit, type AcpStructuredConnection } from './acp-structured-connection' +import { closeAcpSessionJournal, endAcpStructuredSession } from './acp-structured-session' +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 (session.phase === 'starting') { + 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 (session.phase === 'starting') { + endAcpStartingSession(session, observedAt, this.deps.onEvent) + } else { + 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. */ + connectionLost(session: AcpStructuredChild | null, error: Error): void { + if ( + !session || + this.sessions.get(session.sessionId) !== session || + session.journalClosed !== null + ) { + return + } + if (session.phase === 'starting') { + void this.startupFailed(session, error) + return + } + closeAcpSessionJournal( + session, + `${session.spec.agent} ACP connection closed: ${error.message || error.name}` + ) + void this.stop(session.sessionId, false) + } + + async startupFailed(session: AcpStartingSession, error: unknown): Promise { + if (session.ended || session.closeRequested || session.journalClosed !== null) { + return + } + session.failure = acpStartupFailure(session, error) + session.journalClosed = error instanceof Error ? error.message : String(error) + session.dispose() + if (error instanceof AcpConnectionClosedError) { + await waitForAcpExit(session.connection, this.stopGraceMs(), new AbortController().signal) + } + if (session.ended) { + return + } + try { + if (!(await this.stopSession(session.sessionId, false))) { + this.deps.logger?.warn('ACP startup child exit remains unproven', { + scope: 'acp-startup-close', + sessionId: session.sessionId + }) + } + } catch (closeError) { + this.deps.logger?.warn('ACP startup child could not be stopped', { + scope: 'acp-startup-close', + sessionId: session.sessionId, + error: closeError + }) + } + } + + 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 5836975ba6d9..403fa30ec579 100644 --- a/src/main/acp/acp-structured-child-stop.ts +++ b/src/main/acp/acp-structured-child-stop.ts @@ -1,14 +1,18 @@ import type { AcpStructuredSession } from './acp-structured-session' +import type { AcpStructuredChild } from './acp-structured-child' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError } from './acp-errors' import type { AgentSessionBackgroundTaskStops } from '../../shared/agent-child-work-stop-targets' export function acpChildStopCapabilities( - session?: AcpStructuredSession + session?: AcpStructuredChild ): AgentSessionBackgroundTaskStops | undefined { return session?.journalClosed === null - ? { supportsTaskStop: session.subagentStopSupported === true, supportsStopAll: false } + ? { + supportsTaskStop: session.phase === 'ready' && session.subagentStopSupported === 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 000000000000..f85593aed80c --- /dev/null +++ b/src/main/acp/acp-structured-child.ts @@ -0,0 +1,103 @@ +import { + agentSessionFailureFact, + providerDiagnostic, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AcpAgentError } from './acp-errors' +import { acpAuthenticationRequired, acpPromptErrorDetail } from './acp-turn-failures' +import type { AcpStructuredSession } from './acp-structured-session' + +export type AcpStartingSession = Pick< + AcpStructuredSession, + | 'sessionId' + | 'fence' + | 'acquisitionGeneration' + | 'spec' + | 'connection' + | 'closeRequested' + | 'journalClosed' + | 'ended' + | 'exitObservedAt' +> & { + phase: 'starting' + startupAnswered: boolean + failure?: SubmissionRejectionFact + closing?: Promise + dispose: () => void +} + +export type AcpStructuredChild = AcpStartingSession | AcpStructuredSession + +export function acpStartupFailure( + session: AcpStartingSession, + error: unknown +): SubmissionRejectionFact { + const { dialect } = session.spec + const signIn = acpAuthenticationRequired(dialect, error) + const text = + session.connection.stderrTail() || + (error instanceof AcpAgentError + ? signIn + ? acpPromptErrorDetail(dialect, error) + : error.message + : '') + const detail = providerDiagnostic(text, 'person') + return agentSessionFailureFact( + signIn ? 'notSignedIn' : 'providerStartFailed', + detail ? { detail } : {} + ) +} + +export function endAcpStartingSession( + session: AcpStartingSession, + observedAt: number, + onEvent: ((event: StructuredAgentSessionLifecycleEvent) => void) | undefined +): void { + if (session.ended) { + return + } + session.ended = true + session.exitObservedAt = observedAt + session.dispose() + const requested = session.closeRequested + const failure = session.failure ?? acpStartupFailure(session, null) + const detail = providerDiagnostic(session.connection.stderrTail(), 'person') + onEvent?.({ + type: 'ended', + sessionId: session.sessionId, + reason: + session.journalClosed ?? + (session.connection.stderrTail() || `${session.spec.agent} ACP agent ended while starting`), + failure: requested + ? agentSessionFailureFact('hostStopped') + : detail + ? { ...failure, detail } + : failure, + cause: requested ? 'requested-close' : 'unexpected-exit', + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt, + ...(!requested + ? { startupUnproven: true, ...(!session.startupAnswered ? { startupUnanswered: true } : {}) } + : {}) + }) +} + +export function assertAcpStartingSession( + session: AcpStartingSession, + 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-handshake.ts b/src/main/acp/acp-structured-handshake.ts new file mode 100644 index 000000000000..dddc0dcc450b --- /dev/null +++ b/src/main/acp/acp-structured-handshake.ts @@ -0,0 +1,174 @@ +// An ACP child's handshake, run after the host has published it: initialize, reopen the chat's +// session or start a new one, restore its saved options, then report `started`. 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, + StructuredAgentSessionStartedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + ACP_REOPEN_FAILED, + acpReopenTakeover, + acpSessionNotRestoredRow +} from './acp-session-reopen-failure' +import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions' +import { probeAcpChildStop } 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 { restoreAcpSessionOptions, type AcpStructuredOptions } from './acp-structured-options' +import type { AcpStructuredPrompts } from './acp-structured-prompts' +import type { AcpStructuredSession } from './acp-structured-session' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' +import { assertAcpStartingSession, type AcpStartingSession } from './acp-structured-child' + +type AcpHandshakeInput = { + acquire: StructuredAgentSessionAcquireInput + deps: AcpStructuredSessionAdapterDeps + generation: string + launch: AcpStructuredLaunch + connection: AcpStructuredConnection + options: AcpStructuredOptions + prompts: AcpStructuredPrompts + starting: AcpStartingSession + abandoned: () => boolean + lanes: { + create: (providerSessionId: string, attaching?: boolean) => AcpStructuredLane + clear: () => void + finishLoad: () => void + } + onSettled: AcpStructuredTurnsDeps['settle'] + onReady: (session: AcpStructuredSession, event: StructuredAgentSessionStartedEvent) => void +} + +export async function initializeAcpStructuredSession(input: AcpHandshakeInput): Promise { + const { acquire, deps, generation, launch, connection, options, prompts, starting, onSettled } = + input + const { spec } = deps + const sessionId = acquire.identity.sessionId + const now = deps.now ?? Date.now + const assertStarting = () => assertAcpStartingSession(starting, input.abandoned) + const agentName = acpAgentName(spec.agent) + const optionRevision = acquire.optionRevision?.() ?? 0 + assertStarting() + const initialized = await connection.initialize() + starting.startupAnswered = true + 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 auth = authMethodId === undefined ? {} : { authMethodId } + 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, + ...auth + }) + assertStarting() + input.lanes.finishLoad() + attaching.translator.finishLoad() + } catch (error) { + assertStarting() + 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() + input.lanes.clear() + started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) + assertStarting() + liveLane = input.lanes.create(started.sessionId) + } + const subagentStopSupported = await probeAcpChildStop(connection, spec.dialect) + assertStarting() + 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) + assertStarting() + 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 ready: AcpStructuredSession = { + phase: 'ready', + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + spec, + subagentStopSupported, + connection, + lane: liveLane, + prompts, + options, + turns: new AcpStructuredTurns({ + connection, + withdrawRequests: () => prompts.withdrawAll(), + lane: liveLane, + agentName, + now, + settle: onSettled + }), + closeRequested: false, + journalClosed: null, + ended: false, + exitObservedAt: null + } + const reading = liveLane + const unbind = acquire.events?.bindReadingControl?.({ + pauseReading: () => connection.pauseReading(), + resumeReading: () => { + connection.resumeReading() + reading.retry() + } + }) + if (unbind) { + ready.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() + input.onReady(ready, { + type: 'started', + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + link, + reportedOptions: options.read().current, + restoreSkippedOptions: restoreSkipped, + optionRevision + }) +} 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 1099746e82e9..90bbd477f414 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -135,6 +135,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 } } diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index a1486cd3263e..1d188c564984 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('stops a starting child even when Stop names an earlier turn', async () => { const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}) }) @@ -255,7 +258,7 @@ describe('closing or stopping a Grok chat while it starts', () => { turnId: 'turn-of-an-old-child' }) await rig.settle() - expect(rig.child().closes).toBe(0) + expect(rig.child().closes).toBeGreaterThan(0) await host.close(SESSION, 'user-close') await attaching await stopping 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 bf6c7a363c8d..7c75dcea4904 100644 --- a/src/main/acp/acp-structured-host-restore-failed.test.ts +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -114,14 +114,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 ?? [] @@ -154,6 +156,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) @@ -163,14 +166,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 ?? [] @@ -189,19 +194,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 ?? [] @@ -218,13 +226,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') ) @@ -234,32 +243,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) @@ -271,6 +285,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') @@ -279,23 +294,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 216a495e1bc9..a9f846455a10 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -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 000000000000..74fac5c26d67 --- /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 e9ebec7d8855..938cb0b805c2 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 { @@ -126,12 +127,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(), @@ -151,7 +157,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`. */ @@ -205,6 +211,7 @@ export async function openAttachedHostRig( deps: { resolveLaunch: launch(() => resumed), ...deps } }) 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 738ba92bf33c..c8df21f6ce6e 100644 --- a/src/main/acp/acp-structured-missing-model.test.ts +++ b/src/main/acp/acp-structured-missing-model.test.ts @@ -2,7 +2,7 @@ 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 { nativeSessionOptionsFromReport } 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 +27,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 +45,11 @@ 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 + // What the host writes from the report a started child sends. + const options = nativeSessionOptionsFromReport({ + reported: reader.read().current, + restoreSkipped: [] }) - if (!options) { - throw new Error('The live reader must report its options') - } const changed = replaceAgentSessionRecordOptions(record, { sessionId: record.sessionId, fence: record.lease.runtimeFence, @@ -79,14 +76,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 +100,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.ts b/src/main/acp/acp-structured-options.ts index 4d0ea8658432..5d2fa6b19a71 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -248,8 +248,10 @@ export async function restoreAcpSessionOptions( saved: Readonly> | undefined ): Promise { const skipped: string[] = [] - for (const [key, value] of Object.entries(saved ?? {})) { - if (!isAcpStructuredOptionKey(key)) { + // Model before effort: an effort menu belongs to the model it is set on. + for (const key of ['model', 'effort']) { + const value = saved?.[key] + if (value === undefined || !isAcpStructuredOptionKey(key)) { continue } options.notePick(key) 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 000000000000..da7da348e025 --- /dev/null +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -0,0 +1,170 @@ +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('returns at spawn, then reports its real session and restored picks in model-before-effort order', 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' }, + optionRevision: () => 7 + }) + 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() + await rig.frame('session/new') + expect(rig.lifecycle).toEqual([]) + answer?.() + await waitFor(() => + expect(rig.started()).toMatchObject({ + link: { handle: { nativeId: PROVIDER_SESSION }, origin: 'created' }, + reportedOptions: { model: 'grok-4.6', effort: 'low' }, + restoreSkippedOptions: [], + optionRevision: 7 + }) + ) + expect(writes).toEqual(['model', 'reasoning_effort']) + 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(['started', '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 }) + 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) + 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 }) + await rig.frame(answered ? 'session/new' : 'initialize') + rig.child().stderr = 'provider config is invalid' + rig.child().exit() + expect(rig.ended).toMatchObject([ + { startupUnproven: true, failure: { detail: { text: 'provider config is invalid' } } } + ]) + expect(rig.ended[0]?.startupUnanswered).toBe(answered ? undefined : true) + } + ) + + 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')) + }) + await rig.acquire() + expect(rig.ended).toMatchObject([ + { + startupUnproven: true, + cause: 'unexpected-exit', + failure: { kind: 'notSignedIn', detail: { text: 'Authentication required' } } + } + ]) + expect(rig.child().exited).toBe(true) + }) + + it('reports saved picks the provider refused without delaying readiness for another read', 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({ + restoreSkippedOptions: ['model'], + reportedOptions: { model: 'grok-4.7', effort: 'high' } + }) + 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 e20058bfd726..7abdf1eff31f 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 { tick } from './acp-scripted-agent.test-support' import { GROK, @@ -132,7 +131,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 }) @@ -167,7 +165,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' } ]) ) @@ -189,10 +187,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', @@ -208,7 +206,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({ @@ -227,8 +225,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' } } @@ -273,7 +271,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 () => { @@ -287,9 +285,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' }]) }) }) @@ -333,77 +331,58 @@ 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) + await rig.acquire({ waitForStart: false }) 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(rig.child().exited).toBe(true) + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) + expect(rig.lifecycle.some((event) => event.type === 'started')).toBe(false) }) - 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', () => {}) }) + await rig.acquire({ waitForStart: false }) + 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) } 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', () => {}) }) + await rig.acquire({ waitForStart: false }) + 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') + await waitFor(() => expect(child.closes).toBe(1)) + 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([{ startupUnproven: true, startupUnanswered: true }]) expect(await rig.adapter.closeSession(SESSION)).toBe(true) }) @@ -443,7 +422,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 () => { @@ -460,7 +439,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 1818beee8456..046baf272b39 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' @@ -296,8 +295,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' @@ -328,13 +327,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) @@ -359,9 +358,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.ended).toMatchObject([{ failure: { kind: 'notSignedIn' }, startupUnproven: true }]) expect(rig.sent('session/new')).toEqual([]) }) @@ -381,13 +379,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 9d9e3022826e..fab34e911cde 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 ad3dbcd2e3ff..82a3734f7b90 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 { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' @@ -50,7 +46,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 @@ -91,8 +87,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 () => { @@ -100,9 +96,8 @@ 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.ended).toMatchObject([{ startupUnproven: true, failure: { kind: 'notSignedIn' } }]) expect(rig.child().closes).toBe(1) }) }) @@ -116,10 +111,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([ + { startupUnproven: true, 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 () => { @@ -133,9 +128,10 @@ 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([ + { startupUnproven: true, failure: { detail: { text: 'grok: config.toml is invalid' } } } + ]) }) describe('ACP structured session adapter: turns', () => { @@ -412,18 +408,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 () => { @@ -435,7 +431,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 5decdb16c7d4..c67913428c64 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -9,7 +9,6 @@ import type { AgentJournalMessageItem } from '../../shared/agent-session-journal import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionRootExitObservedError, AgentSessionAcquisitionExitUnprovenError, AgentSessionPreSpawnError, isAgentSessionPreSpawnError, @@ -21,19 +20,13 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' -import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' -import { - closeAcpSessionJournal, - endAcpStructuredSession, - 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 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 { awaitAcpTurnEnd, interruptAcpTurn } from './acp-structured-stop' import { acpDispatchPrompt } from './acp-prompt-content' import { ACP_OPTION_WRITE_TIMEOUT_MS, @@ -44,13 +37,14 @@ import { writeAcpSessionOption } from './acp-structured-options' import { readAcpRecoveryHistory } from './acp-recovery-history' import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop' +import type { AcpStructuredChild } from './acp-structured-child' +import { AcpStructuredChildren } from './acp-structured-child-lifecycle' 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() - - constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + private readonly children: AcpStructuredChildren + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) { + this.children = new AcpStructuredChildren(deps) + } /** Restart recovery's evidence; null for an agent whose own store Orca cannot read. */ providerHistoryWindow: NonNullable = ({ @@ -63,9 +57,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte async acquire(input: StructuredAgentSessionAcquireInput): Promise { const sessionId = input.identity.sessionId - const attempt = this.starts.begin(input.signal) + const attempt = this.children.starts.begin(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` @@ -74,7 +68,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } return await this.start(input, attempt) } finally { - this.starts.end(attempt) + this.children.starts.end(attempt) } } @@ -85,26 +79,40 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte const sessionId = input.identity.sessionId const generation = this.deps.mintGeneration?.() ?? randomUUID() try { - const { acquisition, session } = await acquireAcpStructuredSession({ + const { acquisition, session, initialize } = await acquireAcpStructuredSession({ acquire: input, deps: this.deps, generation, abandoned: () => attempt.signal.aborted, - track: (connection) => this.starts.track(attempt, connection), + track: (connection) => this.children.starts.track(attempt, 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.children.connectionLost(session, error), + onReady: (ready, event) => { + const child = this.children.sessions.get(sessionId) + if ( + child?.phase !== 'starting' || + child.ended || + child.closeRequested || + child.journalClosed !== null + ) { + return } + this.children.sessions.set(sessionId, ready) + this.deps.onEvent?.(event) }, - onConnectionLost: (session, error) => this.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) + this.children.sessions.set(sessionId, session) + void initialize().catch((error: unknown) => this.children.startupFailed(session, error)) return acquisition } catch (error) { const { connection } = attempt @@ -117,7 +125,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) { @@ -147,7 +155,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte requestedAt?: number beforeDispatch?: () => Promise }): Promise { - const lost = this.sessions.get(input.sessionId) + 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 this.rejected(lost, 'providerExited') @@ -157,7 +165,7 @@ 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 this.rejected(session, 'providerExited') } @@ -192,14 +200,9 @@ 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. - 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?.phase === 'ready') { await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs()) } } @@ -230,10 +233,27 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return withLiveCatalogListing(options.read(), options.configuredDefault()) } - readOptionRestoreFailures = (sessionId: string): readonly string[] => - this.sessions.get(sessionId)?.restoreSkipped ?? [] + readCommands = (sessionId: string) => { + const session = this.children.sessions.get(sessionId) + return session?.phase === 'ready' ? 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?.phase === 'ready' && session.turns.running + } + + 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 @@ -241,99 +261,30 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte backgroundTaskStops: NonNullable = ( sessionId - ) => acpChildStopCapabilities(this.sessions.get(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) /** 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 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 (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}` - ) - void this.stop(session.sessionId, false) - } - private rejected( - session: AcpStructuredSession, + session: AcpStructuredChild, kind: 'providerExited' ): AgentSessionDispatchOutcome { return { @@ -346,8 +297,8 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } 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?.phase !== 'ready' || 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 629145cf483d..3c1da81f92a7 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -16,6 +16,7 @@ import type { AcpStructuredPrompts } from './acp-structured-prompts' import type { AcpStructuredTurns } from './acp-structured-turns' export type AcpStructuredSession = { + phase: 'ready' sessionId: string fence: number acquisitionGeneration: string @@ -27,8 +28,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. */ diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index f7349b78470c..a1990034e2b5 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -1,9 +1,6 @@ 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 { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { ACP_LAUNCH_SPECS } from './acp-launch-specs' import { @@ -84,12 +81,17 @@ 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 chat opens at spawn, so a refused start ends the child with the reason. + await rig.acquire() + const [ended] = rig.ended + expect(ended).toMatchObject({ + startupUnproven: true, + failure: { kind: 'notSignedIn', detail: { text: detail, audience: 'person' } } + }) + const fact = ended?.failure + if (!fact) { + throw new Error('a refused start reports its failure') + } expect(agentSessionFailureSentence(fact, 'row', { agentName: spec.agent })).toContain(detail) } ) diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index 19a6ee5cf5e1..0c609b60c78d 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/claude/claude-scripted-child.test-fixture.ts b/src/main/claude/claude-scripted-child.test-fixture.ts new file mode 100644 index 000000000000..dfde01fe413b --- /dev/null +++ b/src/main/claude/claude-scripted-child.test-fixture.ts @@ -0,0 +1,164 @@ +// Claude's scripted child for the registration matrix: the real Claude adapter over a scripted +// stream-json connection. Its handshake is the CLI's initialize answer. + +import { randomUUID } from 'node:crypto' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonLaunch, + openClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import type { + ScriptedAgentChild, + ScriptedAgentChildFactory +} from '../runtime/structured-agent-scripted-child.test-fixture' +import { scriptedClaudeExitError } from '../runtime/structured-claude-scripted-runtime-test-support' +import { result, says } from './claude-captured-frame-builders.test-fixture' + +type Spawned = { + connection: Omit & { + closed: boolean + exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] + } + answer: () => void + fail: (message: string) => void +} + +function promptText(message: Record): string { + const inner = message.message + const content = + inner && typeof inner === 'object' && 'content' in inner ? inner.content : undefined + const blocks = Array.isArray(content) ? content : [] + return blocks + .map((block: unknown) => + block && typeof block === 'object' && 'text' in block ? String(block.text) : '' + ) + .join('') +} + +/** The CLI writes its transcript as a conversation's first message lands; a later start resumes it. */ +function writeTranscript(launch: ClaudeStreamJsonLaunch, providerSessionId: string): void { + const home = launch.env?.CLAUDE_CONFIG_DIR + if (!home) { + return + } + const folder = join(home, 'projects', 'scripted') + mkdirSync(folder, { recursive: true }) + writeFileSync(join(folder, `${providerSessionId}.jsonl`), '{}\n') +} + +export const claudeScriptedChild: ScriptedAgentChildFactory = () => { + const spawned: Spawned[] = [] + const prompts: string[] = [] + let resumes = 0 + let closes = 0 + const openConnection: typeof openClaudeStreamJsonConnection = async (launch, handlers = {}) => { + if (launch.options.resume !== undefined) { + resumes += 1 + } + const providerSessionId = String(launch.options.sessionId ?? launch.options.resume) + const initialized = { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } + let answer = (): void => {} + let reject = (_error: Error): void => {} + const init = new Promise((resolve, fail) => { + answer = () => { + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: providerSessionId, + model: 'claude-sonnet-5', + apiKeySource: 'none' + }) + resolve(initialized) + } + reject = fail + }) + if (!child.holdHandshakes) { + answer() + } + const connection: Spawned['connection'] = { + pid: 7000 + spawned.length, + closed: false, + exitVerdict: { root: 'live', tree: 'unverifiable' }, + initializationResult: () => init, + getContextUsage: async () => ({}), + getSettings: async () => ({ effective: {} }), + supportedModels: async () => [{ value: 'sonnet', displayName: 'Sonnet' }], + setModel: async () => {}, + setPermissionMode: async () => {}, + applyFlagSettings: async () => {}, + interrupt: async () => undefined, + cancelAsyncMessage: async () => false, + stopTask: async () => {}, + send: async (message, beforeDispatch) => { + await beforeDispatch?.() + prompts.push(promptText(message)) + writeTranscript(launch, providerSessionId) + // The CLI echoes each message it takes under the client's uuid: Orca's proof of delivery. + handlers.onMessage?.({ + type: 'user', + session_id: providerSessionId, + parent_tool_use_id: null, + uuid: message.uuid, + isReplay: true, + message: message.message + }) + if (child.completeTurns) { + const id = randomUUID() + handlers.onMessage?.({ ...says(id, 'done', null), session_id: providerSessionId }) + handlers.onMessage?.({ ...result('success'), session_id: providerSessionId, uuid: id }) + } + }, + close: async () => { + if (!connection.closed) { + connection.closed = true + closes += 1 + connection.exitVerdict = { root: 'exited', tree: 'exited' } + handlers.onExit?.(new Error('closed by Orca')) + } + return true + } + } + spawned.push({ + connection, + answer, + fail: (message) => { + const error = scriptedClaudeExitError(message) + connection.closed = true + connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } + reject(error) + handlers.onExit?.(error) + } + }) + return connection + } + const newest = (): Spawned => { + const latest = spawned.at(-1) + if (!latest) { + throw new Error('no Claude child was spawned') + } + return latest + } + const child: ScriptedAgentChild = { + deps: { + openClaudeConnection: openConnection, + readProcessStartTime: async (pid) => + spawned.find((entry) => entry.connection.pid === pid)?.connection.exitVerdict.root === + 'exited' + ? null + : pid * 10, + resolveClaudeCommand: () => '/nonexistent/scripted-claude', + resolveClaudeAuthPolicy: () => ({ account: 'system' }) + }, + holdHandshakes: true, + releaseHandshake: () => newest().answer(), + failHandshake: (message) => newest().fail(message), + completeTurns: true, + prompts: () => prompts, + spawns: () => spawned.length, + resumes: () => resumes, + closes: () => closes + } + return child +} diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index a7d1c41c8bfe..7b4f50d54f61 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -63,8 +63,8 @@ export async function cancelClaudeStructuredTurn(input: { const timeoutMs = Math.min(input.timeoutMs ?? CLAUDE_STOP_GRACE_MS, CLAUDE_STOP_GRACE_MS) const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration - // A CLI still starting answers no interrupt: Stop ends its child next (`stopEndsSession`), and - // that end settles every message it was handed and never echoed as stopped. + // A CLI still starting answers no interrupt: Stop ends its child next, and that end settles + // every message it was handed and never echoed as stopped. if (request.prompt || session.startup.state === 'pending') { return { cancelled: false } } diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 96a8c7a1803d..8c18ed9ed783 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -167,7 +167,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { // Init/SessionStart UUIDs are protocol frames, not resumable // main-transcript leaves; no cursor exists before the first user turn. - expect(acquisition.link.handle).toEqual(claudeProviderHandle(providerSessionId, null)) + expect(acquisition.link?.handle).toEqual(claudeProviderHandle(providerSessionId, null)) expect(observedSubtypes).toContain('hook_started') expect(adapter.readCommands('real-cli-handshake')).toContainEqual( expect.objectContaining({ diff --git a/src/main/claude/claude-structured-resume-point.test.ts b/src/main/claude/claude-structured-resume-point.test.ts index 9465feba5d3d..2f5b15b5edde 100644 --- a/src/main/claude/claude-structured-resume-point.test.ts +++ b/src/main/claude/claude-structured-resume-point.test.ts @@ -88,7 +88,7 @@ describe('Claude resume point is the last completed turn on every exit path', () spawnToken: 'spawn-7', events: recordingJournalSink() }) - expect(acquisition.link.handle).toMatchObject({ resumeCursor: 'a3' }) + expect(acquisition.link?.handle).toMatchObject({ resumeCursor: 'a3' }) await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(persisted).toEqual([expect.objectContaining({ leafUuid: 'a3' })]) }) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index a518e13d561c..23ebef841744 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -296,7 +296,7 @@ export async function acquireClaudeSession({ ) } // The start reads its facts only after publish, so the child is `starting` until `started`. - 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.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 0897917718da..683da0c3f760 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -3,8 +3,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRootExitObservedError, - AgentSessionPromptAnswerRejectedError + AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' @@ -26,7 +25,6 @@ import { USER_MESSAGE, type FakeConnection } from './claude-structured-session-test-support' -import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' describe('ClaudeStructuredSessionAdapter.acquire', () => { @@ -129,7 +127,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { 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 } }) @@ -207,7 +204,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect(skipped).toEqual( kept ? [] : [expect.objectContaining({ fence: 7, options: { fastMode: 'true' } })] ) - 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') @@ -219,7 +215,8 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { settings: { applied: {}, effective: {}, sources: {} }, initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] }) - const adapter = adapterFor(claude) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) await adapter.acquire({ identity: identityFor(), @@ -231,7 +228,12 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode']) + // Fast is decided once settings are read, after `started`. + await vi.waitFor(() => + expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ + restoreSkippedOptions: ['fastMode'] + }) + ) }) it.each([ @@ -239,12 +241,17 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { ['permissionMode', { permissionMode: 'retired-mode' }] ] as const)('self-heals a persisted %s the CLI would refuse at launch', async (key, options) => { const claude = fakeClaude() - const adapter = adapterFor(claude) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) await expect( adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options }) ).resolves.toBeDefined() - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + await vi.waitFor(() => + expect(events.find((event) => event.type === 'started')).toMatchObject({ + restoreSkippedOptions: [key] + }) + ) expect(claude.connections[0].launch.options).not.toHaveProperty('effort') expect(claude.connections[0].launch.options).not.toHaveProperty('permissionMode') }) @@ -442,7 +449,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { spawnToken: 'spawn-9' }) - expect(acquisition.link.handle).toEqual(claudeProviderHandle(PROVIDER_SESSION_ID, null)) + expect(acquisition.link?.handle).toEqual(claudeProviderHandle(PROVIDER_SESSION_ID, null)) expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' } @@ -489,8 +496,8 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { fence: 9, spawnToken: 'spawn-9' }) - expect(acquisition.link.origin).toBe('resumed') - expect(acquisition.link.handle).toEqual( + expect(acquisition.link?.origin).toBe('resumed') + expect(acquisition.link?.handle).toEqual( claudeProviderHandle(PROVIDER_SESSION_ID, 'leaf-before') ) @@ -765,157 +772,3 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { expect(connection.close).toHaveBeenCalledTimes(4) }) }) - -describe('ClaudeStructuredSessionAdapter prompts', () => { - it('turns can_use_tool into an addressable durable approval that settles the SDK callback', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) - const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1', { - input: { command: 'git status' }, - suggestions: [{ type: 'addRules' }] - }) - expect(events.at(-1)).toMatchObject({ - type: 'prompt', - prompt: { kind: 'approval', toolName: 'Bash', promptKey: 'permission-1' } - }) - - adapter.bindPromptItemId('session-1', 'journal-approval', 'permission-1') - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-approval', - kind: 'approval', - response: { kind: 'option', optionId: 'allowForSession' }, - fence: 7, - commit: async () => undefined - }) - // The answer resolves the SDK's own callback promise; the SDK writes the wire response. - await expect(answered.promise).resolves.toEqual({ - behavior: 'allow', - updatedInput: { command: 'git status' }, - updatedPermissions: [{ type: 'addRules' }], - toolUseID: 'tool-1' - }) - }) - - it('settles the one AskUserQuestion callback from structured answers, including a long typed answer', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const answered = invokeCanUseTool( - claude.connections[0], - 'AskUserQuestion', - 'question-1', - 'tool-question', - { - input: { - questions: [ - { question: 'Library?', options: [{ label: 'Luxon' }] }, - { question: 'Ship now?', options: [{ label: 'Yes' }] } - ] - } - } - ) - adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') - const typed = 'Wait for the capture to finish first. '.repeat(60) - - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { - kind: 'answers', - answers: [ - { questionId: 'q1', optionIds: ['q1:choice-1'] }, - { questionId: 'q2', optionIds: [], other: typed } - ] - }, - fence: 7, - commit: async () => undefined - }) - await expect(answered.promise).resolves.toMatchObject({ - behavior: 'allow', - updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': typed.trim() } }, - toolUseID: 'tool-question' - }) - }) - - it('refuses answers Claude cannot take before the journal commits them', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const answered = invokeCanUseTool( - claude.connections[0], - 'AskUserQuestion', - 'question-1', - 'tool-question', - { input: { questions: [{ question: 'Library?', options: [{ label: 'Luxon' }] }] } } - ) - adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') - const commit = vi.fn(async () => undefined) - - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit - }) - ).rejects.toBeInstanceOf(AgentSessionPromptAnswerRejectedError) - expect(commit).not.toHaveBeenCalled() - - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }, - fence: 7, - commit - }) - await expect(answered.promise).resolves.toMatchObject({ - updatedInput: { answers: { 'Library?': 'Luxon' } } - }) - }) - - it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) - const controller = new AbortController() - const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-9', 'tool-9', { - input: { command: 'rm -rf /' }, - signal: controller.signal - }) - adapter.bindPromptItemId('session-1', 'journal-9', 'permission-9') - - controller.abort() - // A cancelled request is forgotten and settled with null — never an authorization. - await expect(answered.promise).resolves.toBeNull() - expect(events.at(-1)).toMatchObject({ type: 'prompt-cancelled', promptKey: 'permission-9' }) - // A late answer after the abort must not authorize the wrong tool. - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-9', - kind: 'approval', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit: async () => undefined - }) - ).rejects.toThrow(/no longer waiting/) - }) - - it('settles an in-flight permission callback when the session closes, leaving no dangling promise', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-close', 'tool-c', { - input: { command: 'ls' } - }) - await tick() - expect(answered.settled()).toBe(false) - - await adapter.closeSession('session-1') - - await expect(answered.promise).resolves.toBeNull() - }) -}) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 6f8bc901989e..04b467e23dab 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -13,8 +13,6 @@ import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import { claudeStartupSettledWithin } from './claude-structured-session-startup-state' -import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { ClaudeAcquisitionRegistry, type ClaudeAcquisitionAttempt, @@ -177,9 +175,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.deps.onDispatchSettledLate?.({ sessionId: request.sessionId, ...settlement }), ...(this.deps.requestTimeoutMs === undefined ? {} : { timeoutMs: this.deps.requestTimeoutMs }) }) - // Stop is a session boundary for Claude: an interrupt can answer while background work keeps the - // CLI running, and a refused one leaves the turn running. - stopEndsSession = (): boolean => true awaitStoppedRequestEnd = claudeStoppedRequestEndWait(this.sessions) routePromptCancel = claudePromptCancelRoute dismissPrompt: NonNullable = (request) => @@ -234,18 +229,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda input, this.deps.requestTimeoutMs ) - startAnswered = (sessionId: string): boolean | undefined => - this.sessions.get(sessionId)?.startup.answered - awaitOptionWritable = (sessionId: string): Promise => - claudeStartupSettledWithin( - this.sessions.get(sessionId), - this.deps.requestTimeoutMs ?? CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS - ) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readOptionRestoreFailures = (sessionId: string): readonly string[] => [ - ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) - ] releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) diff --git a/src/main/claude/claude-structured-session-prompts.test.ts b/src/main/claude/claude-structured-session-prompts.test.ts new file mode 100644 index 000000000000..f66ba0c804af --- /dev/null +++ b/src/main/claude/claude-structured-session-prompts.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import { acquired, fakeClaude, tick } from './claude-structured-session-test-support' +import { invokeCanUseTool } from './claude-can-use-tool-test-support' + +describe('ClaudeStructuredSessionAdapter prompts', () => { + it('turns can_use_tool into an addressable durable approval that settles the SDK callback', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + suggestions: [{ type: 'addRules' }] + }) + expect(events.at(-1)).toMatchObject({ + type: 'prompt', + prompt: { kind: 'approval', toolName: 'Bash', promptKey: 'permission-1' } + }) + + adapter.bindPromptItemId('session-1', 'journal-approval', 'permission-1') + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-approval', + kind: 'approval', + response: { kind: 'option', optionId: 'allowForSession' }, + fence: 7, + commit: async () => undefined + }) + // The answer resolves the SDK's own callback promise; the SDK writes the wire response. + await expect(answered.promise).resolves.toEqual({ + behavior: 'allow', + updatedInput: { command: 'git status' }, + updatedPermissions: [{ type: 'addRules' }], + toolUseID: 'tool-1' + }) + }) + + it('settles the one AskUserQuestion callback from structured answers, including a long typed answer', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool( + claude.connections[0], + 'AskUserQuestion', + 'question-1', + 'tool-question', + { + input: { + questions: [ + { question: 'Library?', options: [{ label: 'Luxon' }] }, + { question: 'Ship now?', options: [{ label: 'Yes' }] } + ] + } + } + ) + adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') + const typed = 'Wait for the capture to finish first. '.repeat(60) + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: [], other: typed } + ] + }, + fence: 7, + commit: async () => undefined + }) + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': typed.trim() } }, + toolUseID: 'tool-question' + }) + }) + + it('refuses answers Claude cannot take before the journal commits them', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool( + claude.connections[0], + 'AskUserQuestion', + 'question-1', + 'tool-question', + { input: { questions: [{ question: 'Library?', options: [{ label: 'Luxon' }] }] } } + ) + adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') + const commit = vi.fn(async () => undefined) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { kind: 'option', optionId: 'allow' }, + fence: 7, + commit + }) + ).rejects.toBeInstanceOf(AgentSessionPromptAnswerRejectedError) + expect(commit).not.toHaveBeenCalled() + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }, + fence: 7, + commit + }) + await expect(answered.promise).resolves.toMatchObject({ + updatedInput: { answers: { 'Library?': 'Luxon' } } + }) + }) + + it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const controller = new AbortController() + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-9', 'tool-9', { + input: { command: 'rm -rf /' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-9', 'permission-9') + + controller.abort() + // A cancelled request is forgotten and settled with null — never an authorization. + await expect(answered.promise).resolves.toBeNull() + expect(events.at(-1)).toMatchObject({ type: 'prompt-cancelled', promptKey: 'permission-9' }) + // A late answer after the abort must not authorize the wrong tool. + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-9', + kind: 'approval', + response: { kind: 'option', optionId: 'allow' }, + fence: 7, + commit: async () => undefined + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('settles an in-flight permission callback when the session closes, leaving no dangling promise', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-close', 'tool-c', { + input: { command: 'ls' } + }) + await tick() + expect(answered.settled()).toBe(false) + + await adapter.closeSession('session-1') + + await expect(answered.promise).resolves.toBeNull() + }) +}) diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index 2ece1890fe82..88046e02c787 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -43,23 +43,6 @@ export function claudeStartupFailureFact(session: ClaudeSession): SubmissionReje : null } -/** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ -export function claudeStartupSettledWithin( - session: ClaudeSession | undefined, - timeoutMs: number -): Promise { - if (session?.startup.state !== 'pending') { - return Promise.resolve() - } - let timer: ReturnType | undefined - return Promise.race([ - session.startup.settled, - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs) - }) - ]).finally(() => clearTimeout(timer)) -} - /** Startup cannot land any more: the child exited, was closed, or its start faulted. */ export function failClaudeStartup(session: ClaudeSession, error: Error): void { const startup = session.startup diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 7eece9a4ed6d..eb8496ffb464 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -8,7 +8,6 @@ import { USER_MESSAGE, claudeStartupSettled } from './claude-structured-session-test-support' -import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' /** Far past any deadline a start used to have. */ const NEVER_MS = 10 * 60_000 @@ -134,42 +133,19 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - it('lets an option write wait for startup instead of refusing it', async () => { + // The host records a pick made while the child starts as intent and applies it after `started`. + it('refuses an option write before the CLI answers initialize', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) const pick = { sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 } await expect(adapter.setOption(pick)).rejects.toThrow('still starting') - let writable = false - const waited = adapter.awaitOptionWritable('session-1').then(() => { - writable = true - }) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS - 1) - expect(writable).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await waited - + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) await expect(adapter.setOption(pick)).resolves.toMatchObject({ model: 'opus' }) await adapter.closeAll() }) - it('stops waiting on a start that never lands, so the write is refused as before', async () => { - const claude = fakeClaude({ initDelayMs: 10 * CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS }) - const { adapter } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - let writable = false - void adapter.awaitOptionWritable('session-1').then(() => { - writable = true - }) - await vi.advanceTimersByTimeAsync(CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS) - expect(writable).toBe(true) - await expect( - adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) - ).rejects.toThrow('still starting') - await adapter.closeAll() - }) - // The child was launched with the chat's saved options, so nothing waits on initialize: the SDK // streams the message to the CLI, which takes it behind its own start. it('writes a message sent before initialize answers at once', async () => { @@ -426,7 +402,7 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(events.some((event) => event.type === 'started')).toBe(false) }) - // The CLI answers no control request before initialize; Claude's Stop ends the child instead. + // The CLI answers no control request before initialize; the Stop ends the child instead. it('sends no interrupt to a CLI still starting, though it was handed a message', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) const { adapter } = startingAdapter(claude) @@ -438,7 +414,6 @@ describe('Claude structured session publishes before the CLI answers initialize' }) expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('interrupt') - expect(adapter.stopEndsSession()).toBe(true) await adapter.closeAll() }) }) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 4efcbaf30ba3..7f15b2e3f02d 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -35,15 +35,12 @@ 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: typeof spawnProcess = spawnProcess + spawnImpl: typeof spawnProcess, + initialize: boolean ): Promise { const managed = spawnManagedProviderProcess(launch, { spawnImpl, @@ -233,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) @@ -247,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: typeof spawnProcess = spawnProcess +): Promise { + return connectCodexAppServer(launch, handlers, spawnImpl, false) +} + +export function openCodexAppServerConnection( + launch: CodexAppServerLaunch, + handlers: CodexAppServerConnectionHandlers = {}, + spawnImpl: typeof spawnProcess = spawnProcess +): Promise { + return connectCodexAppServer(launch, handlers, spawnImpl, true) +} diff --git a/src/main/codex/codex-app-server-spawn-publication.test.ts b/src/main/codex/codex-app-server-spawn-publication.test.ts new file mode 100644 index 000000000000..e1656ae4fbb7 --- /dev/null +++ b/src/main/codex/codex-app-server-spawn-publication.test.ts @@ -0,0 +1,40 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { spawnCodexAppServerConnection } from './codex-app-server-connection' +import { initializeCodexAppServerConnection } from './codex-app-server-handshake' + +it('publishes the transport after identity recording and sends initialize only when asked', async () => { + const child = Object.assign(new EventEmitter(), { + pid: 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + const written: string[] = [] + child.stdin.on('data', (chunk: Buffer) => written.push(chunk.toString('utf8'))) + child.stdin.on('finish', () => child.emit('exit', 0, null)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The transport uses only this stub's pid, event emitter, streams and kill. + const spawnImpl = (() => child) as unknown as typeof spawnProcess + const identity = Promise.withResolvers() + const onSpawned = vi.fn(() => identity.promise) + const opening = spawnCodexAppServerConnection( + { command: '/nonexistent/scripted-codex', args: [] }, + { onSpawned }, + spawnImpl + ) + await vi.waitFor(() => expect(onSpawned).toHaveBeenCalledWith(child.pid)) + expect(written).toEqual([]) + identity.resolve() + const connection = await opening + expect(connection.pid).toBe(child.pid) + expect(written).toEqual([]) + const initializing = initializeCodexAppServerConnection(connection) + expect(written[0]).toContain('initialize') + child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`) + await initializing + expect(written.at(-1)).toContain('initialized') + expect(await connection.close()).toBe(true) +}) diff --git a/src/main/codex/codex-collab-call-frames-fixture.ts b/src/main/codex/codex-collab-call-frames-fixture.ts index 8926e46dcca9..acadb0b972cb 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-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts index 9f72f13512f2..3eeb468c1447 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, the host's records and @@ -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-row.test.ts b/src/main/codex/codex-provider-retry-row.test.ts index 7a49e60f279f..cb96549d6d63 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-request-end-wait.test.ts b/src/main/codex/codex-request-end-wait.test.ts new file mode 100644 index 000000000000..b4f00da960c8 --- /dev/null +++ b/src/main/codex/codex-request-end-wait.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { + acquired, + answerWithOpenedTurn, + fakeCodex, + THREAD_ID, + USER_MESSAGE +} from './codex-structured-session-adapter-fixture' + +afterEach(() => vi.useRealTimers()) + +it('lets a Stop wait for the interrupted turn to end, without waiting on its missing echo', async () => { + const codex = fakeCodex() + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') + const adapter = await acquired(codex) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'first', + fence: 7, + body: USER_MESSAGE + }) + await adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + let ended = false + const waiting = adapter.awaitStoppedRequestEnd('session-1', Date.now()).then(() => { + ended = true + }) + await Promise.resolve() + expect(ended).toBe(false) + codex.connections[0].handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted' } + }) + await waiting + expect(ended).toBe(true) + await adapter.closeAll() +}) + +it('spends only the remaining two-second budget from the Stop timestamp', async () => { + const codex = fakeCodex() + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') + const adapter = await acquired(codex) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'first', + fence: 7, + body: USER_MESSAGE + }) + vi.useFakeTimers() + const stoppedAt = Date.now() - 500 + let ended = false + const waiting = adapter.awaitStoppedRequestEnd('session-1', stoppedAt).then(() => { + ended = true + }) + await vi.advanceTimersByTimeAsync(1_499) + expect(ended).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await waiting + expect(ended).toBe(true) + vi.useRealTimers() + await adapter.closeAll() +}) + +it('waits for a dispatch that is still unanswered, then wakes when the child exits', async () => { + const gate = Promise.withResolvers() + const codex = fakeCodex({ 'turn/start': () => gate.promise }) + const adapter = await acquired(codex) + const dispatching = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'first', + fence: 7, + body: USER_MESSAGE + }) + let ended = false + const waiting = adapter.awaitStoppedRequestEnd('session-1', Date.now()).then(() => { + ended = true + }) + await Promise.resolve() + expect(ended).toBe(false) + await adapter.closeSession('session-1') + await waiting + gate.resolve({}) + await dispatching + expect(ended).toBe(true) +}) + +it('resolves at once when there is no live request or child', async () => { + const adapter = await acquired(fakeCodex()) + await adapter.awaitStoppedRequestEnd('session-1', Date.now()) + await adapter.awaitStoppedRequestEnd('absent', Date.now()) + await adapter.closeAll() +}) diff --git a/src/main/codex/codex-request-end-wait.ts b/src/main/codex/codex-request-end-wait.ts new file mode 100644 index 000000000000..a48ccd2d11d0 --- /dev/null +++ b/src/main/codex/codex-request-end-wait.ts @@ -0,0 +1,56 @@ +import type { CodexSession } from './codex-structured-session-state' +import { codexStopTarget } from './codex-structured-turn-open-wait' + +export const CODEX_STOP_GRACE_MS = 2_000 +const waiters = new WeakMap void>>() + +function requestInFlight(session: CodexSession): boolean { + return !session.ended && (session.dispatchPending === true || codexStopTarget(session) !== null) +} + +/** Re-reads the provider's turns after each settlement, within the original Stop's budget. */ +export function codexStoppedRequestEndWait( + sessions: ReadonlyMap +): (sessionId: string, stoppedAt: number) => Promise { + return async (sessionId, stoppedAt) => { + const session = sessions.get(sessionId) + if (!session || !requestInFlight(session)) { + return + } + let elapsed = false + let timer: ReturnType | undefined + const budget = new Promise((resolve) => { + timer = setTimeout( + () => { + elapsed = true + resolve() + }, + Math.max(0, stoppedAt + CODEX_STOP_GRACE_MS - Date.now()) + ) + timer.unref?.() + }) + try { + while (!elapsed && requestInFlight(session)) { + const waiting = waiters.get(session) ?? new Set<() => void>() + waiters.set(session, waiting) + const next = Promise.withResolvers() + waiting.add(next.resolve) + try { + await Promise.race([next.promise, budget]) + } finally { + waiting.delete(next.resolve) + } + } + } finally { + clearTimeout(timer) + } + } +} + +export function settleCodexRequestEndWaiters(session: CodexSession): void { + const waiting = waiters.get(session) + waiters.delete(session) + for (const wake of waiting ?? []) { + wake() + } +} 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 000000000000..526270ff1b7d --- /dev/null +++ b/src/main/codex/codex-scripted-child.test-fixture.ts @@ -0,0 +1,156 @@ +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 { 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 }[] +} + +/** `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) => { + if (ended) { + throw new Error('scripted Codex child closed') + } + requests.push({ method, ...(params ? { params } : {}) }) + if (method === 'initialize') { + return holdHandshakes ? gate.promise : {} + } + 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-background-catalog.ts b/src/main/codex/codex-structured-background-catalog.ts index 5e6dc2f62c32..e371540a6674 100644 --- a/src/main/codex/codex-structured-background-catalog.ts +++ b/src/main/codex/codex-structured-background-catalog.ts @@ -3,7 +3,11 @@ import { fetchCodexModelCatalogListing, type CodexModelCatalogListing } from './codex-structured-model-catalog' -import type { CodexSession } from './codex-structured-session-state' +import { reportedCodexSessionOptions } from './codex-structured-session-options' +import type { + CodexStructuredSessionAdapterDeps, + CodexSession +} from './codex-structured-session-state' import { reconcileCodexFastModeOption } from './codex-structured-fast-mode' type BackgroundCatalogInput = { @@ -12,6 +16,8 @@ type BackgroundCatalogInput = { sessions: ReadonlyMap timeoutMs: number | undefined logger: StructuredAgentSessionLogger | undefined + optionRevision?: () => number + onEvent?: CodexStructuredSessionAdapterDeps['onEvent'] } /** Turns read tiers from the store; only a legacy saved tier needs migrating here. */ @@ -51,6 +57,18 @@ async function refresh(input: BackgroundCatalogInput): Promise { return } const isCurrent = (): boolean => sessions.get(sessionId) === session && !session.ended + const optionRevision = input.optionRevision?.() ?? 0 + const report = (): void => { + input.onEvent?.({ + type: 'options-reported', + sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + reportedOptions: reportedCodexSessionOptions(session), + restoreSkippedOptions: [], + optionRevision + }) + } try { const listing = await fetchCodexModelCatalogListing({ connection: session.connection, @@ -69,6 +87,7 @@ async function refresh(input: BackgroundCatalogInput): Promise { models: latest.models, fastModeTierByModel: new Map(Object.entries(latest.fastModeTierByModel)) }) + report() return } if (fingerprint && store) { @@ -85,6 +104,7 @@ async function refresh(input: BackgroundCatalogInput): Promise { ) } applyListing(session, listing) + report() } catch (error) { if (!isCurrent()) { return 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 4317b5669d77..01d6093825ef 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-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 9ac0f7f31bd9..8c48c86e33cb 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-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 4480df25050f..565e9715a71e 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi, type Mock } from 'vitest' import { + codexStartReport, + acquireReadyCodexForTest, THREAD_ID, USER_MESSAGE, adapterFor, @@ -8,6 +9,7 @@ import { identityFor, type Route } from './codex-structured-session-adapter-fixture' +import { describe, expect, it, vi, type Mock } from 'vitest' import { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AgentModelCatalogStore @@ -30,7 +32,7 @@ function listing(tier = 'priority-live-v2') { } async function acquire(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { - return adapter.acquire({ + return acquireReadyCodexForTest(adapter, { identity: identityFor(sessionId), fence: 7, spawnToken: `spawn-${sessionId}`, @@ -60,8 +62,8 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { const adapter = adapterFor(codex) await acquire(adapter) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toMatchObject({ - fastMode: 'true' + expect(codexStartReport(adapter).reportedOptions).toMatchObject({ + fastMode: true }) await send(adapter, 'first') expect( @@ -309,7 +311,7 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-legacy', @@ -322,8 +324,8 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { finishTurn(codex, 'first-turn') pending.resolve(listing()) await vi.waitFor(() => - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toMatchObject({ - fastMode: 'true' + expect(codexStartReport(adapter).reportedOptions).toMatchObject({ + fastMode: true }) ) codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'second-turn') @@ -408,7 +410,7 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home', resumeThreadId: THREAD_ID }, [], { modelCatalog }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-resume', @@ -416,9 +418,9 @@ describe('Codex structured Fast mode 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', - fastMode: 'true' + fastMode: true }) await send(adapter, 'first') const turn = codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 19fee37a5368..e36a661ecd11 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -211,6 +211,9 @@ export function createCodexJournalTranslator( 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') { const childAdmission = subagents.handleTurnEvent(event) if (!childAdmission.accepted) { 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 f6163cf0558c..d9076548228e 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-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index a0b2dc4146ed..6b973289775e 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-publish-at-spawn.test.ts b/src/main/codex/codex-structured-publish-at-spawn.test.ts new file mode 100644 index 000000000000..3c6ac9c8f9d2 --- /dev/null +++ b/src/main/codex/codex-structured-publish-at-spawn.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from 'vitest' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { + adapterFor, + fakeCodex, + identityFor, + THREAD_ID +} from './codex-structured-session-adapter-fixture' + +const INPUT = { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' } + +function startup(routes: Parameters[0] = {}) { + const events: CodexStructuredSessionEvent[] = [] + const codex = fakeCodex(routes) + const adapter = adapterFor(codex, {}, [], { onEvent: (event) => events.push(event) }) + return { events, codex, adapter } +} + +describe('Codex startup publishes its child before the handshake', () => { + it('returns the recorded process while initialize is held, without inventing a thread link', async () => { + const gate = Promise.withResolvers() + const { adapter, codex, events } = startup({ initialize: () => gate.promise }) + const committed = Promise.withResolvers() + const onSpawned = vi.fn(async () => committed.promise) + const acquiring = adapter.acquire({ ...INPUT, onSpawned }) + await vi.waitFor(() => expect(onSpawned).toHaveBeenCalledOnce()) + expect(codex.connections[0].calls).toEqual([]) + committed.resolve() + const acquisition = await acquiring + expect(acquisition.process.pid).toBe(4321) + expect(acquisition.link).toBeUndefined() + expect(events).toEqual([]) + expect( + adapter.holdsLiveProviderProcess('session-1', acquisition.acquisitionGeneration ?? '') + ).toBe(true) + expect(codex.connections[0].calls.map((call) => call.method)).toEqual(['initialize']) + gate.resolve({}) + await vi.waitFor(() => + expect(events.filter((event) => event.type === 'started')).toHaveLength(1) + ) + await adapter.closeAll() + }) + + it('reports the real thread and saved options after open, before optional catalog reads', async () => { + const thread = Promise.withResolvers() + const catalog = Promise.withResolvers() + const { adapter, codex, events } = startup({ + 'thread/start': () => thread.promise, + 'model/list': () => { + expect(events.some((event) => event.type === 'started')).toBe(true) + return catalog.promise + } + }) + let revision = 2 + await adapter.acquire({ + ...INPUT, + options: { model: 'gpt-saved', effort: 'low', fastMode: 'true' }, + optionRevision: () => revision + }) + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/start')) + expect(events).toEqual([]) + revision = 3 + thread.resolve({ thread: { id: THREAD_ID }, model: 'gpt-old', reasoningEffort: 'high' }) + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: 'started', + link: expect.objectContaining({ + handle: expect.objectContaining({ nativeId: THREAD_ID }) + }), + reportedOptions: { model: 'gpt-saved', effort: 'low', fastMode: true }, + restoreSkippedOptions: [], + optionRevision: 2 + }) + ) + ) + revision = 4 + catalog.resolve({ + data: [{ model: 'gpt-saved', supportedReasoningEfforts: [] }], + nextCursor: null + }) + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ type: 'options-reported', optionRevision: 3 }) + ) + ) + await adapter.closeAll() + }) + + it.each(['initialize', 'thread/start'])( + 'reaps a failed %s and keeps the provider diagnostic', + async (method) => { + const gate = Promise.withResolvers() + const { adapter, codex, events } = startup({ [method]: () => gate.promise }) + await adapter.acquire(INPUT) + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe(method)) + gate.reject(new CodexAppServerRequestError(method, -32603, 'not signed in', 'not signed in')) + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: 'ended', + cause: 'unexpected-exit', + startupUnproven: true, + ...(method === 'initialize' ? { startupUnanswered: true } : {}), + failure: { + kind: 'providerExited', + detail: { text: 'not signed in', audience: 'person' } + } + }) + ) + ) + const end = events.find((event) => event.type === 'ended') + if (method === 'thread/start' && end && 'startupUnanswered' in end) { + expect(end.startupUnanswered).toBeUndefined() + } + expect(events.filter((event) => event.type === 'started')).toEqual([]) + expect(codex.connections[0].closeCount).toBe(1) + await adapter.closeAll() + } + ) + + it.each(['closeSession', 'disposeSession', 'forceCloseSession', 'releaseAcquisition'] as const)( + 'lets %s stop a held handshake with no late started', + async (method) => { + const gate = Promise.withResolvers() + const { adapter, codex, events } = startup({ initialize: () => gate.promise }) + await adapter.acquire(INPUT) + const closed = + method === 'releaseAcquisition' + ? await adapter.releaseAcquisition({ sessionId: 'session-1' }) + : await adapter[method]('session-1') + expect(closed).toBe(true) + expect(codex.connections[0].closeCount).toBe(1) + gate.resolve({}) + await vi.waitFor(() => + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + ) + await new Promise((resolve) => setImmediate(resolve)) + expect(events.filter((event) => event.type === 'started')).toEqual([]) + expect(codex.connections[0].calls.some((call) => call.method === 'thread/start')).toBe(false) + } + ) + + it('ends a child that exits before thread open, without publishing a started event', async () => { + const gate = Promise.withResolvers() + const { adapter, codex, events } = startup({ 'thread/start': () => gate.promise }) + await adapter.acquire(INPUT) + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/start')) + codex.connections[0].closed = true + codex.connections[0].handlers.onExit?.(new Error('child exited')) + gate.resolve({ thread: { id: THREAD_ID } }) + await new Promise((resolve) => setImmediate(resolve)) + expect(events.filter((event) => event.type === 'started')).toEqual([]) + expect(events).toContainEqual(expect.objectContaining({ type: 'ended', startupUnproven: true })) + await adapter.closeAll() + }) + + it('publishes the resumed thread link only after thread/resume answers', async () => { + const gate = Promise.withResolvers() + const codex = fakeCodex({ 'thread/resume': () => gate.promise }) + const events: CodexStructuredSessionEvent[] = [] + const adapter = adapterFor(codex, { resumeThreadId: THREAD_ID }, [], { + onEvent: (event) => events.push(event) + }) + const acquired = await adapter.acquire(INPUT) + expect(acquired.link).toBeUndefined() + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/resume')) + gate.resolve({ thread: { id: THREAD_ID } }) + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: 'started', + link: expect.objectContaining({ + origin: 'resumed', + handle: expect.objectContaining({ nativeId: THREAD_ID }) + }) + }) + ) + ) + await adapter.closeAll() + }) + it('preserves the history-too-large refusal words when restoration ends startup', async () => { + const { adapter, codex, events } = startup({ + 'thread/start': () => ({ + thread: { + id: THREAD_ID, + turns: [ + { + id: 'old-turn', + items: Array.from({ length: 1_025 }, (_, i) => ({ + type: 'agentMessage', + id: `old-${i}`, + text: 'old message' + })) + } + ] + } + }) + }) + const sink: StructuredAgentSessionEventSink = { + appendItem: vi.fn(), + appendTombstone: vi.fn(), + publish: vi.fn() + } + await adapter.acquire({ ...INPUT, events: sink }) + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: 'ended', + startupUnproven: true, + failure: { kind: 'historyTooLarge' } + }) + ) + ) + const event = events.find((entry) => entry.type === 'ended' && 'failure' in entry) + if (!event || !('failure' in event) || !event.failure) { + throw new Error('No failure') + } + expect(agentSessionFailureWords(event.failure, { surface: 'rejection' }).reason).toBe( + "This conversation's history is too large to restore here. Start a new chat to continue." + ) + expect(sink.appendItem).not.toHaveBeenCalled() + expect(codex.connections[0].closeCount).toBe(1) + }) + + it('keeps the exit diagnostic when an initialization timeout has no provider words', async () => { + const gate = Promise.withResolvers() + const { adapter, codex, events } = startup({ initialize: () => gate.promise }) + await adapter.acquire(INPUT) + codex.connections[0].close = async () => { + codex.connections[0].closed = true + codex.connections[0].handlers.onExit?.( + withProviderDiagnostic( + new Error('child exited'), + providerDiagnostic('Please sign in to Codex', 'person') + ), + { expected: true } + ) + return true + } + gate.reject(new Error('initialize timed out')) + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: 'ended', + startupUnproven: true, + startupUnanswered: true, + failure: { + kind: 'providerExited', + detail: { text: 'Please sign in to Codex', audience: 'person' } + } + }) + ) + ) + }) + it('reports a failure while draining early frames once, and leaves no retained acquisition', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + codex.routes['thread/start'] = () => { + codex.connections[0].handlers.onNotification?.('item/started', { threadId: THREAD_ID }) + return { thread: { id: THREAD_ID } } + } + const adapter = adapterFor(codex, {}, [], { + onEvent: (event) => { + if (event.type === 'notification') { + throw new Error('early event failed') + } + events.push(event) + } + }) + await adapter.acquire(INPUT) + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ type: 'ended', startupUnproven: true }) + ) + ) + await adapter.closeAll() + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + expect(events.some((event) => event.type === 'started')).toBe(false) + expect(codex.connections[0].closeCount).toBe(1) + }) +}) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 54cc082c67a1..7e7819952fdc 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,35 +12,20 @@ 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 { spawnCodexAppServerConnection } from './codex-app-server-connection' +import { codexSpawnedProcessIdentity } from './codex-structured-owner-identity' import { codexStructuredChildEnvironment } from './codex-structured-child-environment' -import { openCodexThread } from './codex-structured-thread-open' -import { withCodexVisualsThreadConfig } from './codex-structured-visuals' import { closeCodexPublishedSession, handleCodexSessionExit } from './codex-structured-session-close' -import { restoredCodexSessionOptions } from './codex-structured-session-options' -import { startBackgroundCodexCatalogRefresh } from './codex-structured-background-catalog' -import { - codexAcquireCatalogAccess, - codexAcquireFastModeCatalog -} from './codex-structured-acquire-catalog' -import { - reconcileCodexFastModeOption, - reportedCodexThreadOptions -} from './codex-structured-fast-mode' +import { startCodexStructuredSession } from './codex-structured-session-start' +import { CodexStartingChild } from './codex-structured-starting-child' import { assertCodexConnectionOpen, - codexSessionLifecycle, mintCodexAcquisitionGeneration, type CodexAcquisitionRegistry, type CodexAcquisitionAttempt, @@ -99,7 +83,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({ @@ -161,6 +145,14 @@ export async function acquireCodexStructuredSession(input: { ...(acquireInput.onOutput ? { onOutput: acquireInput.onOutput } : {}), onExit: (error, exit) => { try { + if (attempt.startingChild && !sessions.has(sessionId)) { + attempt.exitProven = true + attempt.startingChild.end( + error, + attempt.cancelled && attempt.requestedClose !== false + ) + acquisitions.deleteIfCurrent(sessionId, attempt) + } handleCodexSessionExit({ sessions, sessionId, @@ -189,96 +181,39 @@ 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 opened = await openCodexThread(connection, threadLaunch, 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) - const fastModeCatalog = codexAcquireFastModeCatalog({ - catalogAccess, - opened, - restoreNeedsCatalog: options.get('fastMode') === 'true' || options.has('serviceTier') + attempt.startingChild = new CodexStartingChild(acquisitionGeneration, { + sessionId, + fence: acquireInput.fence, + onEvent: deps.onEvent, + dispose: () => { + unbindReadingControl?.() + acquisition.prompts.clear() + acquisition.drain() + translator?.dispose() + } }) - 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) - const session: CodexSession = { + void startCodexStructuredSession({ + input: acquireInput, + deps, + launch, account, + sessions, + acquisitions, + attempt, 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 - ), + dispatchEchoes, + subagentExecutions, + setPrimaryThreadId: (threadId) => { + primaryThreadId = threadId + }, + forceCloseUnexpected: input.forceCloseUnexpected, ...(unbindReadingControl ? { unbindReadingControl } : {}) - } - if (fastModeCatalog) { - // The model the next turn sends, as turn/start and the background refresh resolve it. - const model = options.get('model') ?? opened.model ?? fastModeCatalog.result.current.model - reconcileCodexFastModeOption(session, { - fastModeTierByModel: fastModeCatalog.fastModeTierByModel, - currentFastMode: true, - model, - modelFastModeSupport: fastModeCatalog.result.models.find((entry) => entry.id === model) - ?.supportsFastMode - }) - } - sessions.set(sessionId, session) - for (const event of acquisition.drain()) { - event() - } - startBackgroundCodexCatalogRefresh({ - session, - sessionId, - sessions, - timeoutMs: deps.requestTimeoutMs, - logger: deps.logger }) - return acquired + 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 6a7c9f2af5a1..286a676ab076 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -1,3 +1,9 @@ +import { vi } from 'vitest' +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionStartedEvent, + StructuredAgentSessionOptionsReportedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -16,6 +22,40 @@ 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) + await vi.waitFor(() => { + if (adapter.backgroundTaskStops(input.identity.sessionId) === undefined) { + throw new Error('Codex thread not ready') + } + }) + return acquired +} + export const THREAD_ID = 'thread-abc' export function identityFor(sessionId: string): AgentSessionJournalIdentity { @@ -81,6 +121,7 @@ export function fakeCodex(routes: Record = {}): { } } connections.push(connection) + await handlers.onSpawned?.(connection.pid ?? 0) return connection } routes['thread/start'] ??= () => ({ @@ -117,7 +158,12 @@ export function adapterFor( deps: Partial = {} ): CodexStructuredSessionAdapter { let acquisitionGeneration = 0 - return new CodexStructuredSessionAdapter({ + const links = new Map>() + const reports = new Map< + string, + StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent + >() + const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ command: 'codex', args: ['app-server'], @@ -126,13 +172,26 @@ 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 === 'started' && event.link) { + links.set(event.sessionId, event.link) + } + if (event.type === 'started' || 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 +200,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 97821f36c928..6392863ba2df 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 b2154b196cb5..f4ce1b5a1d63 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -1,3 +1,15 @@ +import { + codexStartReport, + codexStartedLink, + acquireReadyCodexForTest, + THREAD_ID, + USER_MESSAGE, + acquired, + adapterFor, + answerWithOpenedTurn, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' import { describe, expect, it, vi } from 'vitest' import { CodexAppServerRequestError, @@ -10,15 +22,6 @@ import { type CodexStructuredLaunch, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' -import { - THREAD_ID, - USER_MESSAGE, - acquired, - adapterFor, - answerWithOpenedTurn, - fakeCodex, - identityFor -} from './codex-structured-session-adapter-fixture' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' @@ -27,19 +30,20 @@ describe('CodexStructuredSessionAdapter.acquire', () => { 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', fastMode: 'true' } }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-saved', effort: 'low', - fastMode: 'true' + fastMode: true }) expect(codex.connections[0].calls.map((call) => call.method)).toEqual([ + 'initialize', 'thread/start', 'model/list' ]) @@ -48,34 +52,28 @@ describe('CodexStructuredSessionAdapter.acquire', () => { it('persists saved model and effort as the next turn uses them', async () => { const codex = fakeCodex() 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', 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' }) + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-saved', effort: 'low' }) }) 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', @@ -90,13 +88,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' }) @@ -110,9 +111,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' }) @@ -126,7 +131,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { const codex = fakeCodex() const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - const acquisition = await adapter.acquire({ + const acquisition = await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' @@ -146,7 +151,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { 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', params: { cwd: '/work/repo' } }) @@ -156,7 +161,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { processStartTimeMs: 1_700_000_000_000, spawnToken: 'spawn-9' }) - expect(acquisition.link).toEqual({ + expect(codexStartedLink(adapter)).toEqual({ linkId: `codex-7-${THREAD_ID}`, handle: codexProviderHandle(THREAD_ID), origin: 'created', @@ -172,9 +177,13 @@ describe('CodexStructuredSessionAdapter.acquire', () => { const codex = fakeCodex() const adapter = adapterFor(codex, { model: 'gpt-chosen' }) - 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(codex.connections[0].calls[0]).toEqual({ + expect(codex.connections[0].calls[1]).toEqual({ method: 'thread/start', params: { cwd: '/work/repo', model: 'gpt-chosen' } }) @@ -187,13 +196,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', @@ -201,8 +210,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 () => { @@ -220,17 +229,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', @@ -243,21 +252,39 @@ describe('CodexStructuredSessionAdapter.acquire', () => { it('refuses a resume that lands on a different thread and reaps the child', async () => { const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) }) - const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' }) - - await expect( - adapter.acquire({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) - ).rejects.toThrow('resumed thread-other instead of thread-proven') + const events: CodexStructuredSessionEvent[] = [] + const unsubscribe = vi.fn((event: CodexStructuredSessionEvent) => events.push(event)) + const failing = adapterFor(codex, { resumeThreadId: 'thread-proven' }, [], { + onEvent: unsubscribe + }) + await failing.acquire({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: 'ended', + startupUnproven: true, + reason: expect.stringContaining('resumed thread-other instead of thread-proven') + }) + ) + ) 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') + const events: CodexStructuredSessionEvent[] = [] + const unsubscribe = vi.fn((event: CodexStructuredSessionEvent) => events.push(event)) + const failing = adapterFor(codex, { resumeThreadId: null }, [], { onEvent: unsubscribe }) + await failing.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-9' }) + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: 'ended', + startupUnproven: true, + reason: expect.stringContaining('did not name the thread') + }) + ) + ) expect(codex.connections[0].closeCount).toBe(1) }) @@ -265,7 +292,11 @@ describe('CodexStructuredSessionAdapter.acquire', () => { const codex = fakeCodex() const adapter = await acquired(codex) - await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 8, + spawnToken: 'spawn-10' + }) expect(codex.connections).toHaveLength(2) expect(codex.connections[0].closeCount).toBe(1) @@ -321,7 +352,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -613,7 +644,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 a5fbf4628a2d..e06c0b60ee07 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -39,6 +39,7 @@ import { } from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' +import { codexStoppedRequestEndWait, settleCodexRequestEndWaiters } from './codex-request-end-wait' import { changeCodexThreadGoal } from './codex-structured-thread-goal' import { answerCodexStructuredPrompt, @@ -143,6 +144,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap // After the journal and the parent's republished row, never ahead of either. session.backgroundTasks.publishChildWork() } + settleCodexRequestEndWaiters(session) this.deps.onEvent?.(event) return admission } @@ -174,6 +176,14 @@ 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?.startingChild?.acquisitionGeneration === acquisitionGeneration) { + return ( + !starting.startingChild.ended && + !starting.exitProven && + starting.window.connection?.pid !== undefined + ) + } return ( session?.acquisitionGeneration === acquisitionGeneration && session.connection.pid !== undefined && @@ -219,6 +229,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) } finally { session.dispatchPending = false + settleCodexRequestEndWaiters(session) } } @@ -229,6 +240,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap requestTimeoutMs: this.deps.requestTimeoutMs }) + awaitStoppedRequestEnd = codexStoppedRequestEndWait(this.sessions) + rewindSupport: NonNullable = (sessionId) => this.sessions.get(sessionId)?.historyMode === 'legacy' ? { supported: false, reason: 'history-not-paginated' } @@ -292,33 +305,6 @@ 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) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) 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 466cadce8a13..aa2af71ae6b9 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 f9f9f72ccb13..7a541a2f59ec 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 e0004a0f7373..cb3121fad606 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' @@ -161,8 +162,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 +171,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 +201,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 465d7801fec9..0e2e0406c5b4 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,3 +1,5 @@ +import { settleCodexRequestEndWaiters } from './codex-request-end-wait' +import { codexStartupFailureFact } from './codex-structured-starting-child' import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { CodexAppServerConnection } from './codex-app-server-connection-types' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' @@ -35,13 +37,25 @@ export function handleCodexSessionExit(input: { // The connection reports the exit inside the close it ends; the close's own reason is the why. reason: ((input.closedByOrca && session.orcaClose?.reason) || input.error).message, // Only the child's own exit blames Codex; a close Orca made, for any reason, is Orca's. - failure: input.closedByOrca - ? agentSessionFailureFact('hostFault') - : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), + failure: session.startingChild + ? codexStartupFailureFact( + session.startingChild.failure ?? input.error, + session.orcaClose?.requested ?? false, + input.error + ) + : input.closedByOrca + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.orcaClose?.requested ? 'requested-close' : 'unexpected-exit', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, - observedAt: session.exitObservedAt + observedAt: session.exitObservedAt, + ...(session.startingChild + ? { + startupUnproven: true, + ...(!session.startingChild.initializeAnswered ? { startupUnanswered: true } : {}) + } + : {}) } as const // A synchronous sink rejection (usually backpressure) leaves the terminal rows to the host's // exit settlement, which writes its own bounded fallback. The exit itself is observed, so the @@ -54,7 +68,11 @@ export function handleCodexSessionExit(input: { reason: admission.reason }) } + if (session.startingChild) { + session.startingChild.ended = true + } session.ended = true + settleCodexRequestEndWaiters(session) // Nothing can echo for this child any more; the journal's pending-submission // recovery is what settles the sends these were armed for. session.dispatchEchoes.clear() @@ -124,16 +142,20 @@ 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))) { + 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 591f0e61ae8f..7d9c6e57150e 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 5176744195b9..cb3b72cab486 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -1,3 +1,11 @@ +import { + acquireReadyCodexForTest, + USER_MESSAGE, + adapterFor, + answerWithOpenedTurn, + fakeCodex, + identityFor +} 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' @@ -13,13 +21,6 @@ import { reportedCodexThreadOptions } from './codex-structured-fast-mode' 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 { AGENT_MODEL_CATALOG_FRESH_MS, AgentModelCatalogStore @@ -564,7 +565,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) ) @@ -578,11 +583,6 @@ describe('Codex option picks before the model list arrives', () => { effort: 'low', fastMode: 'true' }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ - model: 'gpt-next', - effort: 'low', - fastMode: 'true' - }) 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 0a21d17a2e72..c8dc0346b58b 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -34,6 +34,22 @@ export function restoredCodexSessionOptions( return restored } +/** 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) + const fastMode = decodeCodexFastMode(session.options) + return { + model, + ...(effort ? { effort } : {}), + ...(fastMode !== undefined ? { fastMode } : {}) + } +} + 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 4a61f101a7d7..e9771afba1fc 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 44ed5c93d411..189a268d9546 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 000000000000..4e4592db2a4e --- /dev/null +++ b/src/main/codex/codex-structured-session-start.ts @@ -0,0 +1,203 @@ +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' +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 { 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 { closeCodexPublishedSession } from './codex-structured-session-close' +import { + restoredCodexSessionOptions, + reportedCodexSessionOptions +} from './codex-structured-session-options' +import { startBackgroundCodexCatalogRefresh } from './codex-structured-background-catalog' +import { + codexAcquireCatalogAccess, + codexAcquireFastModeCatalog +} from './codex-structured-acquire-catalog' +import { + reconcileCodexFastModeOption, + reportedCodexThreadOptions +} from './codex-structured-fast-mode' +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 CodexStartInput = { + 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 +} + +/** Runs after acquisition publishes the child; optional discovery never holds readiness. */ +export async function startCodexStructuredSession(start: CodexStartInput): Promise { + const { input, deps, launch, sessions, acquisitions, attempt, connection, translator } = start + const sessionId = input.identity.sessionId + const child = attempt.startingChild + if (!child) { + return + } + const current = (): void => { + acquisitions.assertCurrent(sessionId, attempt) + assertCodexConnectionOpen(connection, sessionId) + if (child.ended) { + throw new Error('codex child ended during startup') + } + } + try { + await initializeCodexAppServerConnection(connection) + child.initializeAnswered = true + current() + const threadLaunch = await withCodexVisualsThreadConfig(connection, launch, { + sessionId, + ...(deps.logger ? { logger: deps.logger } : {}) + }) + current() + const optionRevision = input.optionRevision?.() ?? 0 + const opened = await openCodexThread(connection, threadLaunch, deps.requestTimeoutMs) + 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 options = restoredCodexSessionOptions(input.options) + const catalogAccess = codexAcquireCatalogAccess(deps, launch) + const fastModeCatalog = codexAcquireFastModeCatalog({ + catalogAccess, + opened, + restoreNeedsCatalog: options.get('fastMode') === 'true' || options.has('serviceTier') + }) + const session: CodexSession = { + account: start.account, + connection, + startingChild: child, + ...codexSessionLifecycle(input.fence, child.acquisitionGeneration), + threadId: opened.threadId, + historyMode: opened.historyMode, + activeTurnIds: new Set(), + abortedTurnIds: new Set(), + prompts: attempt.window.prompts, + options, + reportedOptions: reportedCodexThreadOptions(opened), + ...(catalogAccess ? { catalogAccess } : {}), + dispatchEchoes: start.dispatchEchoes, + translator, + backgroundTasks: new CodexBackgroundTaskTracker( + opened.threadId, + start.subagentExecutions, + codexChildWorkSink(sessionId, deps) + ), + forceCloseUnexpected: (reason) => + start.forceCloseUnexpected(sessionId, input.fence, child.acquisitionGeneration, reason), + ...(start.unbindReadingControl ? { unbindReadingControl: start.unbindReadingControl } : {}) + } + if (fastModeCatalog) { + const model = options.get('model') ?? opened.model ?? fastModeCatalog.result.current.model + reconcileCodexFastModeOption(session, { + fastModeTierByModel: fastModeCatalog.fastModeTierByModel, + currentFastMode: true, + model, + modelFastModeSupport: fastModeCatalog.result.models.find((entry) => entry.id === model) + ?.supportsFastMode + }) + } + current() + sessions.set(sessionId, session) + for (const event of attempt.window.drain()) { + event() + } + current() + deps.onEvent?.({ + type: 'started', + sessionId, + fence: input.fence, + acquisitionGeneration: child.acquisitionGeneration, + 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), + restoreSkippedOptions: [], + optionRevision + }) + delete session.startingChild + acquisitions.deleteIfCurrent(sessionId, attempt) + startBackgroundCodexCatalogRefresh({ + session, + sessionId, + sessions, + timeoutMs: deps.requestTimeoutMs, + logger: deps.logger, + optionRevision: input.optionRevision, + onEvent: deps.onEvent + }) + } catch (error) { + if (child.ended || attempt.cancelled) { + return + } + child.failure = error instanceof Error ? error : new Error(String(error)) + try { + let exited: boolean + if (sessions.get(sessionId)?.connection === connection) { + exited = await closeCodexPublishedSession(sessions, sessionId, deps.onEvent, { + requestedClose: false, + unexpectedReason: child.failure, + ...(deps.logger ? { logger: deps.logger } : {}) + }) + } else { + exited = await connection.close() + if (exited) { + attempt.exitProven = true + child.end(child.failure) + acquisitions.deleteIfCurrent(sessionId, attempt) + } + } + if (!exited) { + throw new Error('codex startup cleanup could not prove child exit') + } + attempt.exitProven = true + acquisitions.deleteIfCurrent(sessionId, attempt) + } catch (cleanupError) { + try { + deps.logger?.warn('Codex startup cleanup could not prove exit', { + scope: 'codex-startup', + sessionId, + error: cleanupError + }) + } catch { + // Reporting cannot reject startup's background task. + } + } + } +} diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 62347780f853..e468d5e0bf37 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -21,12 +21,13 @@ 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 { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { CodexStartingChild } from './codex-structured-starting-child' import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals-delivery' export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess @@ -82,7 +83,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 } @@ -123,6 +124,7 @@ export type CodexStructuredSessionAdapterDeps = { export type CodexSession = { account?: AgentSessionAccountKind connection: CodexAppServerConnection + startingChild?: CodexStartingChild ended: boolean /** First observed child exit survives rejected settlement admission. */ exitObservedAt?: number @@ -201,7 +203,9 @@ export function requireLiveCodexSession( export type CodexAcquisitionAttempt = { window: CodexAcquisitionWindow + startingChild?: CodexStartingChild cancelled: boolean + requestedClose?: boolean exitProven: boolean finished: Promise finish: () => void @@ -287,17 +291,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.startingChild?.end(new Error('codex session closed'), requestedClose) + } + return closed } diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index e7395fb3c845..12316dcbe800 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-starting-child.ts b/src/main/codex/codex-structured-starting-child.ts new file mode 100644 index 000000000000..9dc006d051a7 --- /dev/null +++ b/src/main/codex/codex-structured-starting-child.ts @@ -0,0 +1,55 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' + +export function codexStartupFailureFact( + failure: Error, + requestedClose: boolean, + exitError?: Error +) { + return requestedClose + ? agentSessionFailureFact('hostFault') + : failure instanceof AgentSessionAcquisitionRefusal && failure.reason === 'historyTooLarge' + ? agentSessionFailureFact('historyTooLarge') + : agentSessionFailureFact('providerExited', { + detail: providerDiagnosticOf(failure) ?? providerDiagnosticOf(exitError) + }) +} + +/** Owns the process during startup, before a thread exists to put in the ready-session map. */ +export class CodexStartingChild { + ended = false + initializeAnswered = false + failure: Error | undefined + + constructor( + readonly acquisitionGeneration: string, + private readonly input: { + sessionId: string + fence: number + onEvent: CodexStructuredSessionAdapterDeps['onEvent'] + dispose: () => void + } + ) {} + + end(error: Error, requestedClose = false): void { + if (this.ended) { + return + } + this.ended = true + const failure = this.failure ?? error + this.input.dispose() + this.input.onEvent?.({ + type: 'ended', + sessionId: this.input.sessionId, + fence: this.input.fence, + acquisitionGeneration: this.acquisitionGeneration, + reason: failure.message, + cause: requestedClose ? 'requested-close' : 'unexpected-exit', + failure: codexStartupFailureFact(failure, requestedClose, error), + observedAt: Date.now(), + startupUnproven: true, + ...(!this.initializeAnswered ? { startupUnanswered: true } : {}) + }) + } +} diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index dfde08edafd1..0a5d00f6f8e1 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, @@ -215,7 +217,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 93430c3d6af7..5e7b7db43d1d 100644 --- a/src/main/codex/codex-structured-visuals.test.ts +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -155,6 +155,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({ @@ -173,9 +174,10 @@ describe('a Codex chat acquisition with visuals', () => { }) 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') - expect(connection.calls[0]?.params).not.toHaveProperty('config') + expect(connection.calls[1]?.params).not.toHaveProperty('config') expect(connection.launch.env).not.toHaveProperty(NATIVE_CHAT_VISUALS_DIR_ENV) }) }) diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts index 89be90dc6e4c..6562bf77a1a0 100644 --- a/src/main/jsonl-rpc/agent-connection.ts +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -1,4 +1,5 @@ import { spawnProcess } from '../../shared/child-process/run-process' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { spawnManagedProviderProcess, type ManagedProviderProcess, @@ -80,10 +81,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-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 29a033a4e7b9..659db49bf33b 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 @@ -50,6 +50,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { StructuredAgentArgumentsError } from '../structured-agent-arguments-error' import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' 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' } @@ -66,6 +67,7 @@ let acquire: Mock let dispatch: Mock let adapterExtras: Partial let idleMs: number +let clock: number const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, @@ -81,12 +83,17 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa } }) +const starts = structuredAgentSessionHeldStarts(() => host) + +/** A child whose handshake is still running: `starting` until the test settles `started`. */ +const spawnStartingChild = starts.held(spawnChild) + async function startHost(): Promise { host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { + adapter: starts.wrap({ acquire, dispatch, closeSession: vi.fn(async () => true), @@ -95,12 +102,12 @@ async function startHost(): Promise { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), ...adapterExtras - }, + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, idleSweep: { intervalMs: 5, idleMs }, - now: () => NOW + now: () => clock }) } @@ -108,7 +115,9 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-accept-deliver-')) resetHostTestOperationIds() adapterExtras = {} + starts.clear() idleMs = 60 * 60_000 + clock = NOW acquire = vi.fn(spawnChild) dispatch = vi.fn(async () => ({ state: 'accepted' as const, @@ -599,15 +608,17 @@ 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 () => { await host.close(SESSION, 'evict') await startHost() - // Each child the loop starts dies as its start step returns: its exit, asked for on the lane - // then, lands before the handover step. + // Each child the loop starts proves its start, then dies as its start step returns: its exit, + // asked for on the lane then, lands before the handover step. acquire.mockImplementation(async (input) => { - const child = await spawnChild(input) + const child = await spawnStartingChild(input) + const acquisitionGeneration = `generation-${acquire.mock.calls.length}` + void starts.prove({ sessionId: SESSION, fence: input.fence, acquisitionGeneration }) void host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, fence: input.fence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + acquisitionGeneration, reason: 'codex app-server crashed', cause: 'unexpected-exit' }) @@ -629,10 +640,7 @@ describe('a child that exits before its message is handed over', () => { describe('a start that fails while messages wait on it', () => { it('keeps one row in the words its rejected messages carry', async () => { // Both are held for a starting child that never proves its start. - acquire.mockImplementation(async (input) => ({ - ...(await spawnChild(input)), - providerChildPhase: 'starting' as const - })) + acquire.mockImplementation(spawnStartingChild) await host.close(SESSION, 'evict') await startHost() @@ -730,10 +738,7 @@ describe('Stop withdraws what is queued', () => { adapterExtras = { closeSession } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => ({ - ...(await spawnChild(input)), - providerChildPhase: 'starting' as const - })) + acquire.mockImplementationOnce(spawnStartingChild) // Held for the starting child, which never proves its start. const id = await accept('hello') await eventually(() => expect(childPhase()).toBe('starting')) @@ -799,7 +804,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 = 0 + idleMs = 1_000 const started = deferred() await host.close(SESSION, 'evict') await startHost() @@ -809,9 +814,12 @@ describe('an eviction between acceptance and handover', () => { }) const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) - // The idle sweep ticks every few milliseconds meanwhile. + // The idle sweep ticks every few milliseconds meanwhile, past the idle window. + clock = NOW + idleMs await new Promise((resolve) => setTimeout(resolve, 20)) + // Fresh again before publish: a start quiet for the whole window is stopped by design. + clock = NOW started.resolve() // Handed to the child it was queued for; the eviction may follow once nothing is owed. await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(1)) @@ -842,10 +850,7 @@ describe('a close that stops the child and then a wind-down step fails', () => { await startHost() acquire.mockImplementationOnce(async (input) => { await started.promise - return { - ...(await spawnChild(input)), - ...(starting ? { providerChildPhase: 'starting' as const } : {}) - } + return (starting ? spawnStartingChild : spawnChild)(input) }) const id = await accept('hello') await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) 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 index 495b7c629989..5a855c40f2e3 100644 --- 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 @@ -3,7 +3,6 @@ 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' @@ -75,11 +74,7 @@ function attachParams( } } -function adapter(input: { - origin: 'created' | 'resumed' - options?: AgentSessionOptionsResult - restoreFailures?: readonly string[] -}): StructuredAgentSessionAdapter { +function adapter(input: { origin: 'created' | 'resumed' }): StructuredAgentSessionAdapter { return { acquire: vi .fn() @@ -98,10 +93,6 @@ function adapter(input: { 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(), @@ -288,231 +279,8 @@ describe('structured session acquisition options', () => { 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', - optionRevision: () => 0, - 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', - optionRevision: () => 0, - 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', - optionRevision: () => 0, - 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', - optionRevision: () => 0, - 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', - optionRevision: () => 0, - 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', - optionRevision: () => 0, - 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'] @@ -525,17 +293,11 @@ describe('structured session acquisition options', () => { 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 base = adapter({ origin: 'created' }) 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') { @@ -563,7 +325,7 @@ describe('structured session acquisition options', () => { } return cleanup }) - const failingAdapter = { ...base, acquire, readOptions, releaseAcquisition } + const failingAdapter = { ...base, acquire, releaseAcquisition } let journalFailure = failurePoint === 'journal' ? injected : null const perform = ( target: AgentSessionRecordStore, 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 6b87efc267d1..c5e5e41464fd 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,19 +1,15 @@ import { isDeepStrictEqual } from 'node:util' -import { waitForPromiseWithSignal } 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' /** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp @@ -35,7 +31,6 @@ export async function acquireOwner( ): Promise<{ record: AgentSessionRecord acquisitionGeneration: string | null - providerChildPhase: StructuredAgentSessionProviderChildPhase }> { const fence = record.lease.runtimeFence const spawnToken = record.lease.reservedSpawnToken @@ -74,28 +69,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, @@ -106,18 +79,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 recorded on `started`. const proved = await input.store.proveOwner({ sessionId: record.sessionId, fence, - link: acquired.link, - now: input.now(), - ...(options ? { options } : {}) + ...(acquired.link ? { link: acquired.link } : {}), + now: input.now() }) - return { - record: proved, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - providerChildPhase - } + return { record: proved, acquisitionGeneration: acquired.acquisitionGeneration ?? null } } catch (error) { if (isAgentSessionPreSpawnError(error)) { throw error 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 27c4a323506d..f03b8b8072d1 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 @@ -63,30 +63,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)) @@ -202,18 +178,7 @@ const QUESTION: AgentJournalQuestionItem = { resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } } -describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { - it("answers for the session's live owner, and keeps the child with none", async () => { - const claude = adapterOf(vi.fn(async () => true)) - claude.stopEndsSession = () => true - const codex = adapterOf(vi.fn(async () => false)) - const router = claudeAndCodexRouter({ claude, codex }, async () => {}) - - expect(router.stopEndsSession('session-1')).toBe(false) - await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) - expect(router.stopEndsSession('session-1')).toBe(true) - }) - +describe('StructuredAgentSessionAdapterRouter Stop wind-down', () => { it("waits on the session's live owner to wind the Stop down, and on nothing with none", async () => { const claude = adapterOf(vi.fn(async () => true)) claude.awaitStoppedRequestEnd = vi.fn(async () => undefined) 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 25e700960780..5a43516178b9 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 @@ -5,7 +5,6 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { StructuredAgentRegistration, StructuredAgentRegistry @@ -131,9 +130,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi startUnavailable: NonNullable = (sessionId) => this.liveOwnerOrNull(sessionId)?.startUnavailable?.(sessionId) - stopEndsSession = (sessionId: string): boolean => - this.liveOwnerOrNull(sessionId)?.stopEndsSession?.(sessionId) ?? false - awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number) => this.liveOwnerOrNull(sessionId)?.awaitStoppedRequestEnd?.(sessionId, stoppedAt) @@ -163,11 +159,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi setOption: StructuredAgentSessionAdapter['setOption'] = (input) => this.owner(input.sessionId).setOption(input) - awaitOptionWritable = (sessionId: string): Promise => - this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() - startAnswered = (sessionId: string): boolean | undefined => - this.liveOwnerOrNull(sessionId)?.startAnswered?.(sessionId) - prepareReadOptions = (input: { sessionId: string; fence: number }) => this.liveOwnerOrNull(input.sessionId)?.prepareReadOptions?.(input) @@ -179,20 +170,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) ?? [] - providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts index a6b12b4f4fd1..e274f8d1a279 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -1,6 +1,6 @@ -// How a provider's Stop, and a prompt card's own Cancel, end what they end. Every member is -// optional: a provider that declares none keeps its child after a Stop it took, and its card's -// Cancel interrupts the turn holding the card. +// How a provider's Stop, and a prompt card's own Cancel, end what they end. A chat's Stop ends the +// child for every provider; every member here is optional, and a card's Cancel with none interrupts +// the turn holding the card. import type { AgentJournalApprovalItem, @@ -23,12 +23,9 @@ export type AgentSessionCancelOutcome = { export type AgentSessionPromptCancelRoute = { kind: 'dismiss' } | { kind: 'stop' } export type StructuredAgentSessionAdapterStop = { - /** A Stop ends this provider's child after `cancelTurn`, whatever it answered, unless it named a - * turn that is no longer live and the cancel answered that it did not take it; the next send - * resumes the conversation. Absent or false keeps the child after a Stop the provider took; a - * failed interrupt still ends a child running the turn the Stop meant. */ - stopEndsSession?(sessionId: string): boolean - /** What a Stop that ends the session waits on before it ends the child: resolves once the provider + /** A chat's Stop ends every provider's child after `cancelTurn`, whatever it answered, unless it + * named a turn that is no longer live and the cancel answered that it did not take it; the next + * send resumes the conversation. This is what the Stop waits on before it ends the child: resolves once the provider * has nothing in flight, a send it has not answered included, or when its grace, counted from * `stoppedAt` (when the interrupt went out), runs out. */ awaitStoppedRequestEnd?(sessionId: string, stoppedAt: number): Promise 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 3525e66e5f66..b9fb99c1abf4 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 @@ -127,18 +127,17 @@ export class AgentSessionAcquisitionExitUnprovenError extends Error { } } -/** 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. */ +/** What a reservation turns into once a process is running under it. Every acquire answers as + * soon as its process exists, before any handshake: the host publishes the child `starting` and + * hands it nothing until its `started` event. */ export type AgentSessionAcquisition = { process: AgentSessionProcessIdentity - link: AgentSessionProviderHandleLink + /** Only a handle the adapter already holds at spawn (one Orca chose itself); a handle the + * provider answers with comes on `started`, never made up here. */ + 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 } /** A refusal before spawn that a person can act on; the site that refused names it. */ @@ -228,13 +227,15 @@ export type StructuredAgentSessionEndedEvent = { 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. */ +/** The child an acquire handed over has now proven its start: its protocol session exists and the + * saved options it launched with are applied. What it reports from here on is fact. */ export type StructuredAgentSessionStartedEvent = { type: 'started' sessionId: string fence: number acquisitionGeneration: string + /** The provider handle its protocol session answered with; absent when the acquisition held it. */ + link?: AgentSessionProviderHandleLink /** 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'] @@ -264,7 +265,7 @@ export type StructuredAgentSessionOptionsSkippedEvent = { * after `started`: persisted as the start's report is. Never delays a start. */ export type StructuredAgentSessionOptionsReportedEvent = Omit< StructuredAgentSessionStartedEvent, - 'type' + 'type' | 'link' > & { type: 'options-reported' } export type StructuredAgentSessionLifecycleEvent = @@ -273,9 +274,8 @@ export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionOptionsReportedEvent | StructuredAgentSessionOptionsSkippedEvent -/** 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 provider child behind an acquisition has proven its start. Every acquire hands + * over a `starting` child, which the host gives no input until the `started` event flips it. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' /** The host's startup attempt (`structured-agent-session-startup-attempt`), which the host always @@ -418,11 +418,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & setOption( input: StructuredAgentSessionSetOptionInput ): Promise>> - /** Resolves once a live session can take an option write, or after a bound; never rejects. */ - awaitOptionWritable?(sessionId: string): 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 @@ -432,17 +427,6 @@ 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. */ - 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 * whether the read has a proven start and whether a turn is still running, so 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 b80135108056..17112f3679a9 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 @@ -126,8 +126,7 @@ beforeEach(async () => { mintedAtFence: fence, observedAt: NOW }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const + acquisitionGeneration: `generation-${++generation}` })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ 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 1563146f7e4a..a759a95b78b5 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 @@ -5,10 +5,7 @@ import { 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 { @@ -52,6 +49,8 @@ import type { StructuredAgentSessionStartupProgress } from './structured-agent-s import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +export type AttachedOwner = 'acquired' | 'kept' | 'none' + export type AttachFlowInput = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter @@ -72,14 +71,12 @@ export type AttachFlowInput = { ) => StructuredAgentSessionStartupProgress /** The conversation's option revision, which the child's reports are stamped with. */ optionRevision: () => number - /** Publishes the journal before clients can send against the new owner. `acquiredOwner` is - * true only when this attach spawned the provider child, so a re-attach to a live one is not - * mistaken for a cold acquire. */ + /** Publishes the journal before clients can send against the new owner. `owner` says whether + * this attach spawned the child (then `starting`), kept a live one, or left the chat at rest. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null, - acquiredOwner: boolean, - providerChildPhase: StructuredAgentSessionProviderChildPhase + owner: AttachedOwner ) => Promise | void /** Host-owned provider sink, bound to the journal inside `onAttached`. */ eventSink?: StructuredAgentSessionEventSink @@ -128,8 +125,7 @@ export async function performAttach( let record: AgentSessionRecord let acquisitionGeneration: string | null = null - let acquiredOwner = false - let providerChildPhase: StructuredAgentSessionProviderChildPhase = 'ready' + let owner: AttachedOwner = 'kept' let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false @@ -193,14 +189,19 @@ export async function performAttach( accountHome: record.accountHome, ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) }) - if (!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 - acquiredOwner = true + owner = 'acquired' } } catch (error) { const wording = { @@ -261,7 +262,7 @@ export async function performAttach( providerHistoryWindow }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) - await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) + await input.onAttached(attached, acquisitionGeneration, owner) await store.recordOperationOutcome({ callerKey: input.callerKey, operationId: params.envelope.clientOperationId, 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 cd419133389c..b776ad3c1012 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 @@ -14,7 +14,11 @@ import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' +import { + performAttach, + type AttachedOwner, + type AttachFlowInput +} from './structured-agent-session-attach-flow' import { endStructuredAgentSessionReleasedChild } from './structured-agent-session-attach-failure' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { @@ -31,7 +35,6 @@ import { 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 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' @@ -205,9 +208,7 @@ async function runAttachUnderAbort( optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { - const conversation = await context.openConversation(record.sessionId, { - acquisition: true - }) + const conversation = await context.openConversation(record.sessionId, { acquisition: true }) if (!conversation) { throw new Error('agent_session_identity_required') } @@ -216,15 +217,16 @@ 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, acquiredOwner, providerChildPhase) => { + onAttached: async (attached, acquisitionGeneration, owner) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null - const startedFor = acquiredOwner ? options.startedFor : current?.startedFor + const startedFor = owner === 'acquired' ? options.startedFor : current?.startedFor // A re-attach to a live child keeps the sink that child already writes through. - const eventSink = acquiredOwner - ? attemptSink - : (context.runtimeState.currentEventSink(sessionId) ?? attemptSink) - if (acquiredOwner) { + const eventSink = + owner === 'acquired' + ? attemptSink + : (context.runtimeState.currentEventSink(sessionId) ?? attemptSink) + if (owner === 'acquired') { // Before the drain: the buffered events are the new child's, never a stale row's. await settleStaleStructuredAgentSessionState({ journal: attached.journal, @@ -238,24 +240,32 @@ async function runAttachUnderAbort( 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, - // A re-attach to a live child keeps what that child already proved, and its cause. - phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready'), - ...(startedFor === undefined ? {} : { startedFor }) + // A replayed create over a chat at rest has no child to index. + if (owner !== 'none') { + attempt.candidate = { + sink: eventSink, + child: { + generation: acquisitionGeneration ?? current?.generation ?? null, + fence, + // Every acquired child starts unproven; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? 'starting' : (current?.phase ?? 'ready'), + ...(startedFor === undefined ? {} : { startedFor }), + ...launchedOptionsOf(owner, attempt.startup, current) + } } } - await recoverStructuredRewind( - context.deps, - sessionId, - attached.journal, - fence, - context.deps.adapter, - context.now - ) + // 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, + context.now + ) + } if (fenceBefore !== null && fence !== fenceBefore) { context.subscribers.snapshot(sessionId, attached.journal, fence) } else { @@ -271,10 +281,6 @@ async function runAttachUnderAbort( indexProviderChild(conversation, candidate.child) if (attempt.startup) { startupAttempts.published(sessionId, attempt.startup.attemptId, candidate.child) - // Only an acquire proves a start; a re-attach to a live child proved nothing. - if (candidate.child.phase === 'ready') { - noteStructuredAgentSessionProviderStarted(context.deps, sessionId) - } } context.publishStatus?.(sessionId) } @@ -289,6 +295,15 @@ async function runAttachUnderAbort( } } +function launchedOptionsOf( + owner: AttachedOwner, + startup: StructuredAgentSessionStartupAttempt | null, + current: StructuredAgentSessionProviderChild | null +): Pick { + const launchedOptions = owner === 'acquired' ? (startup?.options ?? {}) : current?.launchedOptions + return launchedOptions ? { launchedOptions } : {} +} + type AttachCandidate = { child: StructuredAgentSessionProviderChild sink: DeferredStructuredAgentSessionEventSink 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 b6cd948fa4dc..a1643660bb7b 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 @@ -69,10 +69,9 @@ export function mutateWithChatStop( ctx, { reason: 'user-stop', - caller: caller.callerKey, - // A Stop that ends the provider's session ends whatever is in flight, so its event names - // the live turn, or none (the turn opened next), never a named turn that already ended. - ...(ctx.adapter.stopEndsSession?.(ctx.sessionId) === true ? {} : named) + // Every Stop ends the provider's session, so whatever is in flight: its event names the + // live turn, or none (the turn opened next), never a named turn that already ended. + caller: caller.callerKey }, async (tookEffect) => { // Read before the withdrawal it decides on is issued. 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 524b4fd4d3f2..bc4fdf530c5b 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 @@ -25,8 +25,8 @@ export async function joinStructuredAgentSessionChildClose( sessionId: string, child: StructuredAgentSessionProviderChild ): Promise { - // Read before the close: once the child is gone the provider can no longer say. - const unanswered = context.deps.adapter.startAnswered?.(sessionId) === false + // The host hands a starting child nothing, so its close ran nothing it was handed. + const unanswered = child.phase === 'starting' if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } 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 index 88e495b5dbdf..46062cfe290b 100644 --- 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 @@ -12,13 +12,14 @@ 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 { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -30,7 +31,8 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquire: Mock let activeModel: string -let optionWritable: Promise +let dispatch: Mock +let order: string[] let setOption: Mock function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { @@ -47,14 +49,22 @@ function envelope(method: string, fields: Record): AgentSession } function adapter(): StructuredAgentSessionAdapter { - setOption = vi.fn(async ({ value }) => { - activeModel = value - return { model: value } + setOption = vi.fn(async ({ key, value }) => { + order.push(`${key}=${value}`) + if (key === 'model') { + activeModel = value + } + return { [key]: value } + }) + 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 { process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, + acquisitionGeneration: 'generation-1', link: { linkId: `claude-native-${fence}`, handle: claudeProviderHandle(CLAUDE_SESSION, 'native-leaf'), @@ -66,11 +76,10 @@ function adapter(): StructuredAgentSessionAdapter { }) return { acquire, - dispatch: vi.fn(), + dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), setOption, - awaitOptionWritable: () => optionWritable, readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })), closeSession: vi.fn(async () => { activeModel = DEFAULT_MODEL @@ -83,10 +92,10 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-options-')) resetHostTestOperationIds() activeModel = DEFAULT_MODEL - optionWritable = Promise.resolve() + order = [] store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -115,21 +124,39 @@ afterEach(async () => { }) describe('Claude structured session options', () => { - it('queues a pick made while the provider starts only once it can take it', async () => { - let land = (): void => {} - optionWritable = new Promise((resolve) => { - land = resolve - }) - const fields = { key: 'model', value: PICKED_MODEL } - const picked = host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - await new Promise((resolve) => setTimeout(resolve, 20)) + it('takes a pick made while the agent starts as intent and applies it, model first, before the first send', async () => { + const pick = (key: string, value: string) => + host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + // Answered at once: nothing waits on a provider still starting. + expect(await pick('effort', 'high')).toMatchObject({ ok: true }) + expect(await pick('model', PICKED_MODEL)).toMatchObject({ ok: true }) expect(setOption).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options).toEqual({ effort: 'high', model: PICKED_MODEL }) + + const body = hostTestMessage('hello') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + expect(dispatch).not.toHaveBeenCalled() - land() - expect(await picked).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + // The start's own report was read before the picks, so it is out of date. + await host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: 'generation-1', + reportedOptions: { model: DEFAULT_MODEL }, + restoreSkippedOptions: [], + optionRevision: 0 + }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(order).toEqual([`model=${PICKED_MODEL}`, 'effort=high', 'dispatch']) + expect(activeModel).toBe(PICKED_MODEL) + await host.flushAllStreamedEvents() + expect(store.getRecord(SESSION)?.options).toEqual({ effort: 'high', model: PICKED_MODEL }) }) }) 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 3ee2b83a4123..af9d1cf03562 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,11 @@ beforeEach(async () => { adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event) { + if ( + event.type === 'started' || + event.type === 'options-reported' || + (event.type === 'ended' && 'cause' in event) + ) { void host.handleAdapterEvent(event) } } @@ -168,6 +172,8 @@ async function unconfirmedStop() { return false } await expect(stop()).resolves.toMatchObject({ ok: true }) + // The Stop's next step ends the child; its close is what fails to prove the exit. + await host['tasks'].serialize(SESSION, async () => {}) const { notes, turn } = await notesAndTurn() expect(notes).toHaveLength(1) expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) 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 f6d7d6d4be7e..50bb803f6ac6 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 @@ -1,6 +1,7 @@ -// What a Stop's row says when Codex took the interrupt, against the real host, journal and Codex +// What a Stop's row says, and when it ends the child, against the real host, journal and Codex // adapter. Codex answers a turn's interrupt as the turn aborts and sends the turn's end right after -// the answer, so the end reaches Orca a moment after the Stop has its answer. +// the answer, so the end reaches Orca a moment after the Stop has its answer; the Stop's next step +// waits for that end, then ends the child. import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { mkdtemp, rm } from 'node:fs/promises' @@ -65,7 +66,11 @@ 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 === 'started' || + event.type === 'options-reported' || + (event.type === 'ended' && 'cause' in event) + ) { void host.handleAdapterEvent(event) } } @@ -176,6 +181,12 @@ function childEndedByStop(): boolean { return disposeSession.mock.calls.length > 0 } +/** The Stop's second step, which ends the child once Codex has wound down, has run. */ +async function stopSettled(): Promise { + await host['tasks'].serialize(SESSION, async () => {}) + await host.flushStreamedEvents(SESSION) +} + describe('a Codex Stop that Codex answered', () => { it.each([ ['names no turn', undefined], @@ -186,14 +197,15 @@ describe('a Codex Stop that Codex answered', () => { await runningTurn() const stopped = await stop(turnId) - await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['interrupted'])) - await host.flushStreamedEvents(SESSION) + await stopSettled() - expect((await journalRows()).statuses).toEqual(['Cancellation requested.']) + const rows = await journalRows() + expect(rows.statuses).toEqual(['Cancellation requested.']) + expect(rows.turns).toEqual(['interrupted']) expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - // Codex's background terminals live in its child, so a Stop it took keeps the child. - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) + // Every Stop ends the session once Codex has ended the turn; the next send resumes it. + expect(childEndedByStop()).toBe(true) + expect(codex.connections.at(-1)?.closed).toBe(true) } ) }) @@ -213,7 +225,7 @@ describe('a Codex Stop whose interrupt failed', () => { } const stopped = await stop(turnId) - await host.flushStreamedEvents(SESSION) + await stopSettled() expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) @@ -225,7 +237,8 @@ describe('a Codex Stop whose interrupt failed', () => { } ) - it('says Codex did not stop when a Stop naming the running turn could not prove the exit', async () => { + // The Stop has answered by the time the child's end fails, so its note says what is known. + it('says the Stop is unconfirmed when a Stop naming the running turn could not prove the exit', async () => { await runningTurn() codex.routes['turn/interrupt'] = () => { throw interruptFailure('internal error') @@ -233,45 +246,52 @@ describe('a Codex Stop whose interrupt failed', () => { disposeSession.mockResolvedValueOnce(false) const stopped = await stop('turn-1') - await host.flushStreamedEvents(SESSION) + await stopSettled() - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect((await journalRows()).statuses).toEqual([ - "Codex didn't stop: failed to interrupt turn: channel closed." - ]) + const rows = await journalRows() + expect(rows.turns).toEqual(['running']) + expect(rows.statuses).toEqual(['Cancellation was not confirmed.']) }) + // The Stop was pressed on the turn it read live, so it ends the session whatever Codex ran next. it.each([ - ['naming no turn', undefined], - ['naming its turn', 'turn-1'] + ['naming no turn, Codex refused it', undefined, 'refused'], + ['naming its turn, Codex refused it', 'turn-1', 'refused'], + ['naming its turn, the interrupt then failed', 'turn-1', 'internal error'] ] as const)( - 'keeps the child when Codex has moved on to another turn, %s', - async (_case, turnId) => { + 'ends the child when Codex has moved on to another turn, %s', + async (_case, turnId, failure) => { await runningTurn() // Codex ended the turn and opened another before the interrupt reached it. codex.routes['turn/interrupt'] = () => { turns.end('completed') notify('turn/started', { threadId: THREAD, turn: { id: 'turn-2', status: 'inProgress' } }) - throw refused('expected active turn id turn-1 but found turn-2') + throw failure === 'refused' + ? refused('expected active turn id turn-1 but found turn-2') + : interruptFailure(failure) } const stopped = await stop(turnId) - await host.flushStreamedEvents(SESSION) + await stopSettled() - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - expect((await journalRows()).turns).toEqual(['completed', 'running']) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(childEndedByStop()).toBe(true) + expect(codex.connections.at(-1)?.closed).toBe(true) + const rows = await journalRows() + expect(rows.turns).toEqual(['completed', 'interrupted']) + expect(rows.statuses).toEqual(['Cancellation requested.']) } ) - // Codex marks the turn ended before it writes the frame, so its refusal can come first. + // Codex marks the turn ended before it writes the frame, so its refusal can come first. A refused + // interrupt is not waited on: the child's end reaches the turn before Codex's own end does. it.each([ ['naming no turn', undefined], ['naming its turn', 'turn-1'] ] as const)( - "keeps the child when Codex's refusal arrives before the turn's end, %s", + "ends the child when Codex's refusal arrives before the turn's end, %s", async (_case, turnId) => { await runningTurn() codex.routes['turn/interrupt'] = () => { @@ -280,21 +300,23 @@ describe('a Codex Stop whose interrupt failed', () => { } const stopped = await stop(turnId) - await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['completed'])) - await host.flushStreamedEvents(SESSION) + await stopSettled() - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - expect((await journalRows()).outcomes).toEqual(['success']) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(childEndedByStop()).toBe(true) + expect(codex.connections.at(-1)?.closed).toBe(true) + const rows = await journalRows() + expect(rows.outcomes).toEqual(['cancellation']) + expect(rows.statuses).toEqual(['Cancellation requested.']) } ) + // Pressed while the turn read live, the Stop ends the session though the turn ended first. it.each([ ['naming no turn', undefined], ['naming its turn', 'turn-1'] ] as const)( - 'keeps the child when the turn ended before the interrupt reached it, %s', + 'ends the child when the turn ended before the interrupt reached it, %s', async (_case, turnId) => { await runningTurn() codex.routes['turn/interrupt'] = (params) => { @@ -303,12 +325,14 @@ describe('a Codex Stop whose interrupt failed', () => { } const stopped = await stop(turnId) - await host.flushStreamedEvents(SESSION) + await stopSettled() - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - expect((await journalRows()).turns).toEqual(['completed']) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(childEndedByStop()).toBe(true) + expect(codex.connections.at(-1)?.closed).toBe(true) + const rows = await journalRows() + expect(rows.turns).toEqual(['completed']) + expect(rows.outcomes).toEqual(['success']) } ) @@ -328,47 +352,37 @@ describe('a Codex Stop whose interrupt failed', () => { }) // A phone names the turn it shows; a follow-up from elsewhere is not that Stop's to end. - it.each([ - ['Codex refused it', 'refused'], - ['the interrupt went unanswered', 'unanswered'] - ] as const)( - 'keeps the child when a Stop names a turn that ended and a follow-up has not opened, %s', - async (_case, failure) => { - await runningTurn() - turns.end('completed') - await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['completed'])) - await followUpUnopened() - codex.routes['turn/interrupt'] = - failure === 'refused' - ? turns.routes['turn/interrupt'] - : () => { - throw interruptFailure('unanswered') - } - - const stopped = await stop('turn-1') - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - } - ) + it('keeps the child when a Stop names a turn that ended and a follow-up has not opened, Codex refused it', async () => { + await runningTurn() + turns.end('completed') + await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['completed'])) + await followUpUnopened() + codex.routes['turn/interrupt'] = turns.routes['turn/interrupt'] + + const stopped = await stop('turn-1') + await stopSettled() + + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(childEndedByStop()).toBe(false) + expect(codex.connections.at(-1)?.closed).toBe(false) + }) - it("decides on Codex's frames received before the interrupt failed, not on the journal's last write", async () => { + // An interrupt whose answer was lost may have stopped the follow-up, so the session ends. + it('ends the child when a Stop names a turn that ended and its interrupt of a follow-up went unanswered', async () => { await runningTurn() - // Each frame lands in the journal as Codex hands it over, before the interrupt fails. + turns.end('completed') + await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['completed'])) + await followUpUnopened() codex.routes['turn/interrupt'] = () => { - turns.end('completed') - notify('turn/started', { threadId: THREAD, turn: { id: 'turn-2', status: 'inProgress' } }) - throw interruptFailure('internal error') + throw interruptFailure('unanswered') } const stopped = await stop('turn-1') - await host.flushStreamedEvents(SESSION) + await stopSettled() - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect((await journalRows()).turns).toEqual(['completed', 'running']) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(childEndedByStop()).toBe(true) + expect(codex.connections.at(-1)?.closed).toBe(true) }) it('holds a card queued before it while the child it ends goes, its event written first', async () => { @@ -418,7 +432,9 @@ describe('a Codex Stop whose interrupt failed', () => { expect(stopAt).toBeLessThan(endAt ?? 0) }) - it('leaves a child that exited during the interrupt to its exit', async () => { + // The exit lands behind the Stop's step, which ends the session it was pressed for: the turn reads + // as the person's cancellation, with one row, and the stop finds the root already gone. + it('ends a child that exited during the interrupt as the Stop, with one row', async () => { await runningTurn() codex.routes['turn/interrupt'] = () => { const exited = new Error('codex app-server exited') @@ -427,10 +443,18 @@ describe('a Codex Stop whose interrupt failed', () => { } const stopped = await stop() - await host.flushStreamedEvents(SESSION) + await stopSettled() - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ + cause: 'user-stop', + rootGone: true + }) + const rows = await journalRows() + expect(rows.outcomes).toEqual(['cancellation']) + expect(rows.statuses).toEqual(['Cancellation requested.']) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts index ef007e03d6be..c1d70a17f5c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -143,7 +143,7 @@ describe('restart resume around /compact (B14)', () => { function markerFor(items: AgentJournalRenderItem[], tasks: boolean) { return structuredAgentSessionWorkingAtStop({ sessionId: SESSION, - session: { journal: journal(items), child: { fence: 1 } }, + session: { journal: journal(items), child: { fence: 1, phase: 'ready' as const } }, getRecord: () => record(), childWork: () => tasks ? [childRecord({ id: 'task-a', kind: 'agent', description: 'Review' })] : [], 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 index 4e6b575ef1f1..53dcb9d420f7 100644 --- 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 @@ -224,10 +224,7 @@ 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(async (input) => ({ - ...(await spawn(input)), - providerChildPhase: 'starting' as const - })) + 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)) 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 431ca9e761de..649da24b2890 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 @@ -21,7 +21,10 @@ import { type StructuredAgentSessionLifetimeContext, type StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -103,33 +106,35 @@ export function createStructuredAgentSessionConversationLifetime(host: { ...deps().idleSweep }) + const stopStartingChild = ( + sessionId: string, + expired: StructuredAgentSessionProviderChildIdentity | null + ): void => { + void serialize(sessionId, async () => { + const child = sessions.get(sessionId)?.child + if (!expired || !child || child.phase !== 'starting' || !sameProviderChild(child, expired)) { + return + } + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'host-stop' + }) + }).catch((error: unknown) => + deps().logger.warn('stopping an agent that could not finish starting failed', { + scope: 'startup-limit', + sessionId, + error + }) + ) + } return { idleSweep, stopAgent, /** A host stop of the child the expired attempt published, if it is still that one and still * starting: its end fails what is queued, as the idle sweep's stop of a start does. */ - expireStartup: ({ sessionId, child: expired }: StructuredAgentSessionExpiredStartup): void => { - void serialize(sessionId, async () => { - const child = sessions.get(sessionId)?.child - if ( - !expired || - !child || - child.phase !== 'starting' || - !sameProviderChild(child, expired) - ) { - return - } - await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { - cause: 'host-stop' - }) - }).catch((error: unknown) => - deps().logger.warn('stopping an agent past its startup limit failed', { - scope: 'startup-limit', - sessionId, - error - }) - ) - }, + expireStartup: ({ sessionId, child }: StructuredAgentSessionExpiredStartup): void => + stopStartingChild(sessionId, child), + /** A start that cannot finish ends as one past its limit does, outside the caller's step. */ + stopStartingChild, /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true 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 3154d165ca62..868de54be29b 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 @@ -13,9 +13,14 @@ import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-se import { CancelParams } from '../../../shared/rpc-contract/structured-agent-session-params' 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 { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' import { @@ -33,6 +38,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { THREAD_ID, + acquireReadyCodexForTest, adapterFor, answerWithOpenedTurn, fakeCodex, @@ -42,6 +48,7 @@ import { AgentModelCatalogStore } from '../agent-model-catalog/agent-model-catal import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' const CALLER = { callerKey: 'client-1' } +const REFUSAL_DETAIL = { text: 'failed to interrupt turn', audience: 'person' as const } let root: string let store: AgentSessionRecordStore @@ -53,10 +60,12 @@ let setOption: Mock let acknowledgeSessionRelease: Mock< NonNullable > -/** Codex's answer by default: its Stop keeps the child. */ -let stopEndsSession: boolean let log: ReturnType let events: StructuredAgentSessionEventSink | undefined +/** Set by a test whose later children are real Codex ones; the first stays the fake. */ +let acquireNext: + | ((input: StructuredAgentSessionAcquireInput) => Promise) + | undefined function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) @@ -66,48 +75,57 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-conversation-stop-')) resetHostTestOperationIds() events = undefined + acquireNext = undefined // Admitted, not accepted: the message is written and its turn has not opened. dispatch = vi.fn(async () => ({ state: 'admitted' as const })) cancelTurn = vi.fn(async () => ({ cancelled: true })) closeSession = vi.fn(async () => true) setOption = vi.fn(async () => undefined) acknowledgeSessionRelease = vi.fn() - stopEndsSession = false log = recordingStructuredAgentSessionLogger() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: log.logger, store, - adapter: { - acquire: async ({ fence, spawnToken, events: sink }) => { - events = sink - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { + adapter: startsWhenPublished( + { + acquire: async (input) => { + const { fence, spawnToken, events: sink } = input + events = sink + // A child started after a Stop ended the last one resumes the chat's thread. + const resumes = (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + const link = { linkId: `link-${fence}`, handle: codexProviderHandle(THREAD), - origin: 'created' as const, + origin: resumes ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, observedAt: NOW } - } + if (acquireNext) { + return { ...(await acquireNext(input)), link } + } + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + acquisitionGeneration: 'generation-1', + link + } + }, + dispatch, + closeSession, + acknowledgeSessionRelease, + releaseAcquisition: vi.fn(async () => true), + cancelTurn, + answerPrompt: vi.fn(async () => undefined), + setOption }, - dispatch, - closeSession, - acknowledgeSessionRelease, - releaseAcquisition: vi.fn(async () => true), - cancelTurn, - stopEndsSession: () => stopEndsSession, - answerPrompt: vi.fn(async () => undefined), - setOption - }, + () => host + ), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', @@ -194,6 +212,11 @@ async function statusRows(): Promise { ) } +/** A Stop's second step, which ends the child, runs next on the session's lane. */ +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + describe('a Stop that names no turn', () => { it('does not let a stalled catalog hold an option pick, steer, Stop, or replacement send', async () => { const pending = Promise.withResolvers() @@ -203,7 +226,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', @@ -214,6 +237,9 @@ describe('a Stop that names no turn', () => { dispatch.mockImplementation((input) => adapter.dispatch(input)) cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) setOption.mockImplementation((input) => adapter.setOption(input)) + // The Stop ends this child; the replacement send starts a fresh Codex child. + closeSession.mockImplementation((sessionId) => adapter.closeSession(sessionId)) + acquireNext = (input) => acquireReadyCodexForTest(adapter, input) const first = send('first send') await first.result @@ -238,16 +264,17 @@ describe('a Stop that names no turn', () => { threadId: THREAD_ID, turn: { id: 'turn-1', status: 'interrupted' } }) + await eventually(() => expect(codex.connections[0].closed).toBe(true)) codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-2') const replacement = send('replacement') - await replacement.result + expect(await replacement.result).toMatchObject({ ok: true }) await eventually(() => expect( - codex.connections[0].calls.filter((call) => call.method === 'turn/start') - ).toHaveLength(2) + codex.connections[1]?.calls.filter((call) => call.method === 'turn/start') + ).toHaveLength(1) ) expect( - codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params + codex.connections[1].calls.find((call) => call.method === 'turn/start')?.params ).toMatchObject({ model: 'gpt-next', serviceTier: 'default' }) pending.resolve({ data: [], nextCursor: null }) }) @@ -275,7 +302,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', @@ -283,13 +310,16 @@ describe('a Stop that names no turn', () => { }) dispatch.mockImplementation((input) => adapter.dispatch(input)) cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) + closeSession.mockImplementation((sessionId) => adapter.closeSession(sessionId)) const first = send('first send') await first.result await eventually(async () => expect((await submission(first.id))?.handedOverAt).toBeDefined()) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) await new Promise((resolve) => setTimeout(resolve, 20)) + await laneDrained() await host.flushStreamedEvents(SESSION) + expect(codex.connections[0].closed).toBe(true) expect(await submission(first.id)).toMatchObject({ dispatchState: 'rejected', @@ -317,7 +347,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 +376,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 +416,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)) @@ -418,8 +452,8 @@ describe('a Stop that names no turn', () => { pending.resolve({ data: [], nextCursor: null }) }) - // The provider took the Stop on the turn it named and never opened it: no turn-ended event will - // come, so the Stop's answer settles the send, and its own row says it never started. + // The provider took the Stop on the turn it named and never opened it: the Stop ends the child, + // whose end takes the send back, and the next send reaches the child that resumes the chat. it('delivers a newly accepted message after Stop withdraws the prior send', async () => { const first = send('first send') await first.result @@ -427,13 +461,15 @@ describe('a Stop that names no turn', () => { cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-never-opened' }) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) expect(await submission(first.id)).toMatchObject({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) expect(await statusRows()).toEqual([]) const replacement = send('replacement send') - await replacement.result + expect(await replacement.result).toMatchObject({ ok: true }) await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) expect(dispatch.mock.calls[1]![0].clientMessageId).toBe(replacement.id) @@ -464,7 +500,7 @@ describe('a Stop that names no turn', () => { }) // Codex's own shape: the turn's start is read before the interrupt's answer, so the turn has a - // record, which the Stop ends; the send opened it and stays as it is. + // record, which the child's end settles; the send opened it and stays as it is. it('ends the turn the provider opened before answering, and withdraws nothing', async () => { const first = send('first send') await first.result @@ -479,8 +515,11 @@ describe('a Stop that names no turn', () => { }) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + await host.flushStreamedEvents(SESSION) - expect((await submission(first.id))?.dispatchState).toBe('pending') + expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + expect((await submission(first.id))?.dispatchState).not.toBe('rejected') const turn = (await host.journalSnapshot(SESSION)).items.find( (item) => item.body.kind === 'turn' ) @@ -488,24 +527,7 @@ describe('a Stop that names no turn', () => { expect(await statusRows()).toEqual(['Cancellation requested.']) }) - it('withdraws nothing on a refusal, even one that names the turn', async () => { - const first = send('first send') - await first.result - await eventually(async () => expect((await submission(first.id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - turnId: 'turn-never-opened', - refusal: { - detail: { text: 'no active turn to interrupt', audience: 'person' }, - turnNotRunning: true - } - }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect((await submission(first.id))?.dispatchState).toBe('pending') - }) - + // The child's end settles what the Stop took; a failed write there is reported, never the Stop's. it('answers the Stop when settling the send it took fails, and reports the failure', async () => { const first = send('first send') await first.result @@ -514,14 +536,17 @@ describe('a Stop that names no turn', () => { if (!journal) { throw new Error('expected the conversation open') } - vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('the journal threw')) + // The child's end settles the send in one journal write. + vi.spyOn(journal, 'appendLifecycleBatch').mockRejectedValueOnce(new Error('the journal threw')) cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-never-opened' }) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) expect(log.entries).toContainEqual( expect.objectContaining({ - fields: expect.objectContaining({ scope: 'stop-settle', sessionId: SESSION }) + fields: expect.objectContaining({ scope: 'exit-settlement', sessionId: SESSION }) }) ) }) @@ -561,7 +586,14 @@ describe('a Stop that names no turn', () => { expect(cancelTurn).toHaveBeenCalledTimes(1) expect(cancelTurn.mock.calls[0]![0]).not.toHaveProperty('turnId') expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 1 }) - expect(await statusRows()).toEqual(['Cancellation requested.']) + await laneDrained() + // The child's end took the unopened send back; its own row says so, so the Stop writes none. + expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + expect(await statusRows()).toEqual([]) }) it('withdraws what is queued on a ready child and asks the provider for nothing more', async () => { @@ -608,6 +640,7 @@ describe('a Stop that names no turn', () => { }) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) // Its turn never opened, so the child's end took the send back: no row stays to report on it. @@ -615,24 +648,6 @@ describe('a Stop that names no turn', () => { expect(await statusRows()).toEqual([]) }) - it('says the agent did not stop, in its words, when it refused because its turn is not running', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { - detail: { text: 'no active turn to interrupt', audience: 'person' }, - turnNotRunning: true - } - }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect(closeSession).not.toHaveBeenCalled() - expect(await statusRows()).toEqual(["Codex didn't stop: no active turn to interrupt."]) - }) - it('ends the child when the provider never answered the interrupt', async () => { const { id, result } = send('hello') await result @@ -641,6 +656,7 @@ describe('a Stop that names no turn', () => { cancelTurn.mockRejectedValueOnce(new Error('codex app-server turn/interrupt exceeded 30000ms')) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) // Its turn never opened, so the child's end took the send back: no row stays to report on it. @@ -648,26 +664,35 @@ describe('a Stop that names no turn', () => { expect(await statusRows()).toEqual([]) }) - it('says the agent did not stop, in its words, when it could not interrupt and the child end is unproven', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' } } - }) - closeSession.mockResolvedValueOnce(false) + // The Stop has answered by the time its child's end fails, so its note is what says so. + it.each([ + ['refused', { cancelled: false, refusal: { detail: REFUSAL_DETAIL } }], + ['went unanswered', new Error('codex app-server turn/interrupt exceeded 30000ms')] + ])( + 'says the Stop is unconfirmed when the interrupt %s and the child end is unproven', + async (_case, interrupt) => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + if (interrupt instanceof Error) { + cancelTurn.mockRejectedValueOnce(interrupt) + } else { + cancelTurn.mockResolvedValueOnce(interrupt) + } + closeSession.mockResolvedValueOnce(false) - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(log.entries).toContainEqual( - expect.objectContaining({ - fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION }) - }) - ) - expect(await statusRows()).toEqual(["Codex didn't stop: failed to interrupt turn."]) - }) + expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + expect(log.entries).toContainEqual( + expect.objectContaining({ + fields: expect.objectContaining({ scope: 'chat-stop', sessionId: SESSION }) + }) + ) + expect(await statusRows()).toEqual(['Cancellation was not confirmed.']) + } + ) it('counts as stopped when the child exit was proven and only a later cleanup step failed', async () => { const { id, result } = send('hello') @@ -679,6 +704,7 @@ describe('a Stop that names no turn', () => { }) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) // Bookkeeping after the proven exit: reported, never the Stop's failure. @@ -691,29 +717,6 @@ describe('a Stop that names no turn', () => { expect(await statusRows()).toEqual([]) }) - it('says the Stop is unconfirmed, not that nothing ran, when neither the interrupt nor the child end is proven', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockRejectedValueOnce(new Error('codex app-server turn/interrupt exceeded 30000ms')) - closeSession.mockResolvedValueOnce(false) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect(await statusRows()).toEqual(['Cancellation was not confirmed.']) - }) - - it('says the agent had no turn to stop when it had none', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect(await statusRows()).toEqual(['Codex had no turn running to stop.']) - }) - it('stops nothing when it reuses the id of a Stop the host already ran', async () => { const operationId = hostTestOperationId() expect(await stop(undefined, operationId)).toMatchObject({ ok: true, replayed: false }) @@ -828,42 +831,60 @@ describe('a Stop that names its turn, as an older client sends it', () => { }) }) -describe('a Stop on a provider whose Stop ends its session', () => { +describe('a Stop ends the agent session', () => { async function handedOver(): Promise { const { id, result } = send('hello') await result await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) } - /** The Stop's second step, which ends the child, runs next on the session's lane. */ - function laneDrained(): Promise { - return host['tasks'].serialize(SESSION, async () => {}) + /** The handed-over send's turn opened, so the child's end takes nothing back. */ + async function turnRunning(): Promise { + await handedOver() + hostEvents().appendItem( + { provider: 'legacy', agent: 'codex', sessionId: SESSION, recordId: 'turn:turn-1' }, + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) } - it('ends the child after the cancel even when the provider refused it, and says only that it was asked', async () => { - stopEndsSession = true + it('ends the child after a Stop the provider took', async () => { await handedOver() - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } } - }) + cancelTurn.mockResolvedValueOnce({ cancelled: true }) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + // The Stop answers first; its next step on the session's lane ends the child. await laneDrained() - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) + expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) + // Whatever the provider said of the interrupt, the child's end is what stops the work. + it.each([ + ['refused it', { cancelled: false, refusal: { detail: REFUSAL_DETAIL } }], + [ + 'refused it as no turn running', + { cancelled: false, refusal: { detail: REFUSAL_DETAIL, turnNotRunning: true as const } } + ], + ['said it had no turn', { cancelled: false }] + ])( + 'ends the child after the cancel even when the provider %s, and says only that it was asked', + async (_case, answer) => { + await turnRunning() + cancelTurn.mockResolvedValueOnce(answer) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(await statusRows()).toEqual(['Cancellation requested.']) + } + ) + it('ends the child and says it was asked when the provider declined a Stop naming the live turn', async () => { - stopEndsSession = true - await handedOver() - events!.appendItem( - { provider: 'legacy', agent: 'codex', sessionId: SESSION, recordId: 'turn:turn-1' }, - { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) + await turnRunning() cancelTurn.mockResolvedValueOnce({ cancelled: false }) // Ending the session stops the turn, so this Stop stopped something and writes its one note. @@ -874,18 +895,7 @@ describe('a Stop on a provider whose Stop ends its session', () => { expect(await statusRows()).toEqual(['Cancellation requested.']) }) - it('keeps the child of a provider whose Stop is not a session boundary', async () => { - await handedOver() - cancelTurn.mockResolvedValueOnce({ cancelled: true }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await laneDrained() - - expect(closeSession).not.toHaveBeenCalled() - }) - it('leaves the child alone when the Stop named a turn that is no longer live', async () => { - stopEndsSession = true cancelTurn.mockResolvedValueOnce({ cancelled: false }) expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: false } }) @@ -897,7 +907,6 @@ describe('a Stop on a provider whose Stop ends its session', () => { }) it("ends the child when the provider's cancel of a Stop naming a turn no longer live fails", async () => { - stopEndsSession = true await handedOver() // The interrupt went out and its answer was lost: what it stopped is unknown. cancelTurn.mockRejectedValueOnce(new Error('control request lost')) @@ -910,7 +919,6 @@ describe('a Stop on a provider whose Stop ends its session', () => { }) it('ends the child when the provider took a Stop naming a turn that is no longer live', async () => { - stopEndsSession = true await handedOver() // The interrupt stopped the follow-up in flight, which has no turn a client could name. cancelTurn.mockResolvedValueOnce({ cancelled: true }) 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 e10d215dc80a..e393cbf0d157 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-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts index c3f2a4855986..652b81ad1efa 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 @@ -194,10 +194,11 @@ it('holds the created first message until the child proves its start, then hands const first = firstMessage() const spawn = rig.adapter.acquire.getMockImplementation()! let generation = '' + const holdStart = rig.heldStart(spawn) rig.adapter.acquire.mockImplementationOnce(async (input) => { - const child = await spawn(input) + const child = await holdStart(input) generation = child.acquisitionGeneration ?? '' - return { ...child, providerChildPhase: 'starting' as const } + return child }) expect( await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) @@ -229,10 +230,7 @@ it('holds the created first message until the child proves its start, then hands it('Stop while the child proves its start withdraws the created first message once, unsent', async () => { const first = firstMessage() const spawn = rig.adapter.acquire.getMockImplementation()! - rig.adapter.acquire.mockImplementationOnce(async (input) => ({ - ...(await spawn(input)), - providerChildPhase: 'starting' as const - })) + rig.adapter.acquire.mockImplementationOnce(rig.heldStart(spawn)) expect( await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) ).toMatchObject({ ok: true }) 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 51894a9ff8d7..43db82db38bd 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 @@ -6,7 +6,8 @@ import { stopAgentSessionProviderRoot } from './structured-agent-session-provide import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChild + StructuredAgentSessionProviderChild, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { @@ -37,6 +38,10 @@ export class StructuredAgentSessionEventRecovery { now: () => number runtimeState: StructuredAgentSessionHostRuntimeState wakeDelivery: (sessionId: string) => Promise + stopStartingChild: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => void } ) {} 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 new file mode 100644 index 000000000000..1cf90b4749ed --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -0,0 +1,62 @@ +// A fake adapter's children prove their start once published, except those a test holds `starting`. + +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionLifecycleEvent +} from './structured-agent-session-adapter' + +type Acquire = StructuredAgentSessionAdapter['acquire'] + +/** A report read at this revision is never admitted, so the empty report persists nothing. */ +const NO_OPTIONS_REPORTED = -1 + +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 + /** Settles `started` for a child, as its provider answering the handshake does. */ + prove(child: { sessionId: string; fence: number; acquisitionGeneration: string }): Promise + clear(): void +} + +export function structuredAgentSessionHeldStarts( + host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } +): StructuredAgentSessionHeldStarts { + const heldGenerations = new Set() + let generations = 0 + const prove: StructuredAgentSessionHeldStarts['prove'] = (child) => + host().handleAdapterEvent({ + type: 'started', + ...child, + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: NO_OPTIONS_REPORTED + }) + return { + held: (acquire) => async (input) => { + const acquired = await acquire(input) + const acquisitionGeneration = acquired.acquisitionGeneration ?? `held-start-${++generations}` + heldGenerations.add(acquisitionGeneration) + return { ...acquired, acquisitionGeneration } + }, + wrap: (adapter) => { + const acquire: Acquire = async (input) => { + const acquired = await adapter.acquire(input) + const acquisitionGeneration = + acquired.acquisitionGeneration ?? `instant-start-${++generations}` + if (!heldGenerations.has(acquisitionGeneration)) { + void prove({ + sessionId: input.identity.sessionId, + fence: input.fence, + acquisitionGeneration + }) + } + return { ...acquired, acquisitionGeneration } + } + return { ...adapter, acquire } + }, + prove, + clear: () => heldGenerations.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 9ff8543d514e..0ed0cb3a5472 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 @@ -189,7 +189,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-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index cb9cd10fea68..0d76b61eccac 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 @@ -19,7 +19,7 @@ import type { AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' -import { threadGoalPlan } from './structured-agent-session-thread-goal' +import { refuseThreadGoalUnsupported, threadGoalPlan } from './structured-agent-session-thread-goal' import { mutateStructuredAgentSession, type StructuredAgentSessionMutationContext @@ -41,6 +41,7 @@ import { agentSessionMutationAdmitsNow } from './structured-agent-session-mutati import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' +import { runAfterProviderStart } from './structured-agent-session-provider-start-hold' import { performSetOption } from './structured-agent-session-turns-options' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' @@ -71,23 +72,25 @@ export function sendStructuredAgentSessionTurn( arrival?: Parameters[2] ): Promise> { const plan = sendPlan(params) - return mutateStructuredAgentSession( - context, - caller, - params.envelope, - { - ...plan, - run: (ctx) => - runQueueableStructuredAgentSessionSend( - context, - ctx, - params, - async () => - structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? - (await plan.run(ctx)) - ) - }, - sendPreparation(context, params.envelope, arrival) + return runAfterProviderStart(context, params.envelope.sessionId, () => + mutateStructuredAgentSession( + context, + caller, + params.envelope, + { + ...plan, + run: (ctx) => + runQueueableStructuredAgentSessionSend( + context, + ctx, + params, + async () => + structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? + (await plan.run(ctx)) + ) + }, + sendPreparation(context, params.envelope, arrival) + ) ) } @@ -174,23 +177,27 @@ export async function setStructuredAgentSessionOption( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { - // Outside the queue: a pick made while the provider starts then queues behind what its start persists. - await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) const plan = setOptionPlan(params) - const atRest = () => !context.sessions.get(params.envelope.sessionId)?.child + // With no child, or one still starting, the pick is intent: the next start replays it, and a + // starting child takes it before it is handed anything. Never a write the pick waits on. + const recordsIntent = () => { + const child = context.sessions.get(params.envelope.sessionId)?.child + return !child || child.phase === 'starting' + } return mutateStructuredAgentSession( context, caller, params.envelope, { ...plan, - // Read as the call is admitted: with no child running, the pick is a conversation write — - // intent the next start replays. A running child's pick is still its owner's to make. + // Read as the call is admitted; a ready child's pick is still its owner's to make. get conversationWrite() { - return atRest() ? (true as const) : undefined + return recordsIntent() ? (true as const) : undefined }, run: async (ctx) => { - if (atRest()) { + if (recordsIntent()) { + // A report the starting child read before this pick is out of date. + context.optionRevisions.advance(params.envelope.sessionId) return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) } // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. @@ -213,12 +220,17 @@ export function changeStructuredAgentSessionThreadGoal( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - return mutateStructuredAgentSession( - context, - caller, - params.envelope, - threadGoalPlan(params), - openWithAgent(context, params.envelope) + return runAfterProviderStart(context, params.envelope.sessionId, () => + mutateStructuredAgentSession( + context, + caller, + params.envelope, + threadGoalPlan(params), + // An agent without goals answers so at once, with no start to wait out. + openWithAgent(context, params.envelope, (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 d604b805eced..cbae46441bfe 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 @@ -42,14 +42,11 @@ export class StructuredAgentSessionHostRuntimeState { ...(deps.startupLimits ? { limits: deps.startupLimits } : {}), settled: recordAgentSessionStartup, expire: (expired) => { - if (expired.child === null) { - this.acquireAborts.abort( - expired.sessionId, - new StructuredAgentSessionStartupExpiredError() - ) - return + // Still acquiring, or applying the picks made meanwhile: either wait ends now. + this.acquireAborts.abort(expired.sessionId, new StructuredAgentSessionStartupExpiredError()) + if (expired.child !== null) { + onStartupExpired?.(expired) } - onStartupExpired?.(expired) } }) this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({ 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 ab748f866b5b..d4fb189ede09 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 @@ -32,6 +32,7 @@ import { import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' 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' const journals = createTrackedJournalOpener() @@ -83,17 +84,20 @@ function accepted(): AgentSessionDispatchOutcome { } function adapter(): StructuredAgentSessionAdapter { - return { - acquire, - releaseAcquisition, - dispatch: async (input) => { - await input.beforeDispatch?.() - return dispatch(input) + return startsWhenPublished( + { + acquire, + releaseAcquisition, + dispatch: async (input) => { + await input.beforeDispatch?.() + return dispatch(input) + }, + cancelTurn, + answerPrompt, + setOption }, - cancelTurn, - answerPrompt, - setOption - } + () => host + ) } async function attach(): Promise { 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 d6372878b2bd..4f2395727988 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 @@ -67,12 +67,15 @@ export type StructuredAgentSessionChildClose = { /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { - /** A publish-first acquire is `starting` until the adapter's `started` event; only then are its - * reported options fact. */ + /** Every acquired child is `starting` until the host settles its `started` event; 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 * for any other start. In memory only: it tells a restart offer its own start from another. */ readonly startedFor?: string + /** The saved options its start launched with: a pick made while it started that differs is + * applied before it is accepted as ready. */ + readonly launchedOptions?: Readonly> close?: StructuredAgentSessionChildClose } 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 e92b33aac1c1..fd8d82363b78 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 @@ -252,14 +252,19 @@ describe('cancel', () => { expect(JSON.stringify(page.ok && page.page.items[0]?.body)).not.toContain('turn-1') }) - it('reports an unconfirmed cancellation rather than failing the call', async () => { + it('ends the child when its interrupt goes unanswered, rather than failing the call', async () => { await attach() cancelTurn.mockRejectedValueOnce(new Error('no answer')) const result = await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), turnId: 'turn-1' }) - expect(result).toMatchObject({ ok: true, value: { cancelled: false } }) + // Every Stop ends the session, so the child's end confirms it whatever the interrupt answered. + expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) + await host['tasks'].serialize(SESSION, async () => {}) + expect(host['sessions'].get(SESSION)?.child).toBeNull() + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items[0]?.body).toMatchObject({ text: 'Cancellation requested.' }) }) it('never interrupts twice on a replay', async () => { 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 072bb865c34f..0d0f138aac6e 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 @@ -152,7 +152,8 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), now: () => this.now(), runtimeState: this.runtimeState, - wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId) + wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), + stopStartingChild: (...args) => this.lifetime.stopStartingChild(...args) }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), @@ -254,8 +255,7 @@ export class StructuredAgentSessionHost { // type, and this file has no line budget left for the import. /** Quit: no exit or recovery settled after this starts a child or hands a message over, and the * queue hands no card over. */ - stopDelivery = (): void => - [this.conversationDelivery, this.queued.drain].forEach((d) => d.dispose()) + stopDelivery = () => [this.conversationDelivery, this.queued.drain].forEach((d) => d.dispose()) async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { this.stopDelivery() 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 new file mode 100644 index 000000000000..61b62598beeb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -0,0 +1,35 @@ +// A fake adapter whose child proves its start the moment it is published, as a provider with an +// instant handshake does. Every acquire answers before its handshake now, so a fake that should +// behave like a ready provider reports `started` itself; the host queues it behind the attach +// that publishes the child. + +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionLifecycleEvent +} from './structured-agent-session-adapter' + +/** A report read at this revision is never admitted, so the fake's empty report persists nothing, + * as a fake with no options to report never did. */ +const NO_OPTIONS_REPORTED = -1 + +export function startsWhenPublished( + adapter: A, + host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } +): A { + let generations = 0 + const acquire: StructuredAgentSessionAdapter['acquire'] = async (input) => { + const acquired = await adapter.acquire(input) + const acquisitionGeneration = acquired.acquisitionGeneration ?? `instant-start-${++generations}` + void host().handleAdapterEvent({ + type: 'started', + sessionId: input.identity.sessionId, + fence: input.fence, + acquisitionGeneration, + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: NO_OPTIONS_REPORTED + }) + return { ...acquired, acquisitionGeneration } + } + return { ...adapter, acquire } +} 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 268b71d152aa..96370376918c 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 @@ -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 { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -101,29 +102,32 @@ beforeEach(async () => { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - 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: 'created' as const, - mintedAtFence: fence, - observedAt: NOW - } - })), - releaseAcquisition: vi.fn(async () => true), - dispatch, - closeSession, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + adapter: startsWhenPublished( + { + 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: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + })), + releaseAcquisition: vi.fn(async () => true), + dispatch, + closeSession, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + () => host + ), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', 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 918afba6c5b3..d32ea5f05a56 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 @@ -27,6 +27,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 { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -48,15 +49,18 @@ function openHost(): void { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + adapter: startsWhenPublished( + { + acquire, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + dispatch, + 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}`, 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 index 763acc16c781..d00c72f22c5b 100644 --- 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 @@ -40,6 +40,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 { 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' } @@ -68,33 +69,36 @@ beforeEach(async () => { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async (input) => { - events = input.events - return { - 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 + adapter: startsWhenPublished( + { + acquire: async (input) => { + events = input.events + return { + 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) }, - 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', 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 7d2a1e20067b..e7c712b4b7e5 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 @@ -140,7 +140,7 @@ export function sendPlan(params: { .submissions() .find((entry) => entry.clientMessageId === clientMessageId) if (submission) { - return { clientMessageId, submission } + return { clientMessageId, submission: { ...submission } } } // A pending row wrote nothing, so the send runs for the first time. Succeeded: accepted, // then a new epoch dropped its row. Unknown: only builds before this one wrote that. 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 index 63bbbe64d93b..b827836af61f 100644 --- 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 @@ -1,25 +1,6 @@ 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 and Fast replace the * saved ones, other saved options stay, and any the child could not take are dropped. */ export function nativeSessionOptionsFromReport(input: { @@ -31,6 +12,7 @@ export function nativeSessionOptionsFromReport(input: { }): Readonly> { const { reported, priorOptions } = input const restored = priorOptions ? { ...priorOptions } : {} + // A report naming no model drops the saved one: the next start runs the provider's own. delete restored.model delete restored.effort delete restored.fastMode 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 index 67085dfbcf22..30e1e372f11f 100644 --- 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 @@ -56,10 +56,24 @@ function envelope(method: string, fields: Record): AgentSession } function adapter(): StructuredAgentSessionAdapter { - acquire = vi.fn(async ({ fence, spawnToken, options }) => { + 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 } : {}) }, + restoreSkippedOptions: [], + optionRevision: host + .collaboratorsForTests() + .runtimeState.optionRevisions.current(identity.sessionId) + }) return { + acquisitionGeneration, process: { hostId: 'local', pid: 4200 + acquire.mock.calls.length, 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 7810ace25677..a4edddc9ec74 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 @@ -133,7 +133,8 @@ export async function readStructuredAgentSessionOptions( throw journalOpenReadRefusal(error, context.deps.logger, sessionId) }) const child = session?.child - if (!child) { + // A starting child is asked nothing: the answer is the saved intent and the catalog. + if (!child || child.phase === 'starting') { return { kind: 'rest' as const } } const prepared = adapter.prepareReadOptions?.({ sessionId, fence: child.fence }) 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 index c7a9997117e3..c0037c442d9c 100644 --- 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 @@ -44,6 +44,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' } const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -79,27 +80,26 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa } }) -/** A Claude-shaped child: published at spawn, so it is `starting` until `started`. */ -const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (input) => ({ - ...(await spawnChild(input)), - providerChildPhase: 'starting' as const -}) +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: { - acquire, + 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 - }, + ...adapterExtras, + acquire + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, @@ -116,6 +116,7 @@ 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, @@ -530,14 +531,15 @@ describe('a start another operation made that dies while a sent message waits on 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 exits as its start step returns: that exit, asked for on the lane - // then, lands before the handover step. + // 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 spawnChild(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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-start-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-start-wait.test.ts new file mode 100644 index 000000000000..adc87fa88d25 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-start-wait.test.ts @@ -0,0 +1,51 @@ +// A wait on a child's start follows that child, not the object the host keeps for it: a re-attach +// rebuilds the same child, and only a different child ends the wait. + +import { describe, expect, it } from 'vitest' +import { + indexProviderChild, + markProviderChildStarted, + providerChildStartSettled +} from './structured-agent-session-provider-child' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' + +function bearer(child: StructuredAgentSessionProviderChild) { + return { child, journal: { cursor: () => ({ epoch: 'e1', sequence: 0 }) } } +} + +const starting = (generation: string): StructuredAgentSessionProviderChild => ({ + generation, + fence: 1, + phase: 'starting' +}) + +describe('a start wait across a re-index of the child', () => { + it('stays with the same child rebuilt by a re-attach, and settles ready once it starts', async () => { + const session = bearer(starting('gen-1')) + const wait = providerChildStartSettled(session) + const rebuilt = starting('gen-1') + + indexProviderChild(session, rebuilt) + markProviderChildStarted(session, rebuilt) + + expect(await wait).toBe('ready') + }) + + it('settles ready when the rebuilt child has already proven its start', async () => { + const session = bearer(starting('gen-1')) + const wait = providerChildStartSettled(session) + + indexProviderChild(session, { ...starting('gen-1'), phase: 'ready' }) + + expect(await wait).toBe('ready') + }) + + it('settles ended when a different child replaces the one waited on', async () => { + const session = bearer(starting('gen-1')) + const wait = providerChildStartSettled(session) + + indexProviderChild(session, starting('gen-2')) + + expect(await wait).toBe('ended') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 978d65d055bf..e123109b4d23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -29,14 +29,81 @@ export function structuredAgentSessionConversationFence( return store.getRecord(sessionId)?.lease.runtimeFence ?? 0 } +/** How a start someone waited on ended: proven, or the child is gone. */ +export type StructuredAgentSessionChildStartOutcome = 'ready' | 'ended' + +const startWaits = new WeakMap< + StructuredAgentSessionProviderChild, + PromiseWithResolvers +>() + +function settleStartWait( + child: StructuredAgentSessionProviderChild, + outcome: StructuredAgentSessionChildStartOutcome +): void { + startWaits.get(child)?.resolve(outcome) + startWaits.delete(child) +} + +/** Settles when the session's child leaves `starting`; at once when it has none starting. Every + * move out of `starting` is made in this file, so none can leave a waiter behind. */ +export function providerChildStartSettled( + session: Pick | undefined +): Promise { + const child = session?.child + if (!child) { + return Promise.resolve('ended') + } + if (child.phase !== 'starting') { + return Promise.resolve('ready') + } + let wait = startWaits.get(child) + if (!wait) { + wait = Promise.withResolvers() + startWaits.set(child, wait) + } + return wait.promise +} + /** For the end of a successful attach only: a failed one never wrote a child to take back. */ export function indexProviderChild( session: ChildBearer, child: StructuredAgentSessionProviderChild ): void { + const previous = session.child + if (previous && previous !== child) { + if (sameProviderChild(previous, child)) { + carryStartWait(previous, child) + } else { + settleStartWait(previous, 'ended') + } + } session.child = child } +/** A re-attach rebuilds the same child (a second window, a phone, a reconnect): its start is still + * the one waited on. */ +function carryStartWait( + from: StructuredAgentSessionProviderChild, + to: StructuredAgentSessionProviderChild +): void { + const wait = startWaits.get(from) + startWaits.delete(from) + if (!wait) { + return + } + if (to.phase !== 'starting') { + wait.resolve('ready') + return + } + const existing = startWaits.get(to) + if (existing) { + void existing.promise.then(wait.resolve) + } else { + startWaits.set(to, wait) + } +} + export function markProviderChildStarted( session: ChildBearer, identity: StructuredAgentSessionProviderChildIdentity @@ -44,6 +111,7 @@ export function markProviderChildStarted( const child = matchingChild(session, identity) if (child) { child.phase = 'ready' + settleStartWait(child, 'ready') } return child !== null } @@ -91,6 +159,7 @@ export function endProviderChild( return false } session.child = null + settleStartWait(child, 'ended') session.lastEndedChild = { ...ended, ...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts new file mode 100644 index 000000000000..425d4e64766d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts @@ -0,0 +1,90 @@ +// One rule for every agent: an operation only the agent's protocol session can perform (a rewind, a +// goal, the rewind recovery a send needs) never reaches a child still proving its start, which has +// no protocol session yet. Its preparation answers `providerStarting` while the child starts; the +// call then waits here, outside the session's queue, because the start's own step runs in that +// queue, and is decided afresh once the start is proven. A start that ends unproven fails the call. + +import { + refuse, + refuseUnclassified, + type AgentSessionMutationResult, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' +import { + providerChildStartSettled, + type StructuredAgentSessionChildStartOutcome +} from './structured-agent-session-provider-child' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from './structured-agent-session-startup-attempt-contract' + +const PROVIDER_STARTING: AgentSessionWireRefusal = refuse( + 'agent_session_operation_invalid', + { reason: 'providerStarting' }, + 'The agent is still starting.' +) + +/** The refusal a preparation answers with while the session's child has not proven its start. */ +export function refuseWhileProviderStarting( + session: Pick | undefined +): { ok: false; refusal: AgentSessionWireRefusal } | null { + return session?.child?.phase === 'starting' ? { ok: false, refusal: PROVIDER_STARTING } : null +} + +/** The startup limit always ends a start; the ceiling only bounds a wait nothing else would. */ +async function awaitProviderChildStart( + session: Pick | undefined, + ceilingMs: number = STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS +): Promise { + let timer: ReturnType | undefined + const timedOut = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), ceilingMs) + timer.unref?.() + }) + try { + return await Promise.race([providerChildStartSettled(session), timedOut]) + } finally { + clearTimeout(timer) + } +} + +function heldForStart(result: AgentSessionMutationResult): boolean { + return !result.ok && result.refusal.details?.reason === 'providerStarting' +} + +/** Runs `run`, and once more after the start it found under way is proven. Never called inside the + * session's queue. A second start found starting is refused, never waited on again. */ +export async function runAfterProviderStart( + context: { + sessions: ReadonlyMap> + deps: Pick + }, + sessionId: string, + run: () => Promise> +): Promise> { + const first = await run() + if (!heldForStart(first)) { + return first + } + const started = await awaitProviderChildStart( + context.sessions.get(sessionId), + context.deps.startupLimits?.ceilingMs + ) + if (started === 'timeout') { + return first + } + if (started === 'ended') { + // The start's own failure is the chat's to show; the call says only that it did not start. + return { + ok: false, + refusal: refuseUnclassified( + 'agent_session_owner_restart_failed', + agentSessionWriteNoticeEnglish(['restartFailed']) + ) + } + } + return run() +} 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 index 7543580e4304..67a939aceabd 100644 --- 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 @@ -28,7 +28,7 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost const providerStarted = vi.fn() -function startHost(phase: 'ready' | 'starting'): void { +function startHost(): void { const acquire: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { @@ -38,8 +38,7 @@ function startHost(phase: 'ready' | 'starting'): void { mintedAtFence: fence, observedAt: NOW }, - acquisitionGeneration: 'generation-1', - providerChildPhase: phase + acquisitionGeneration: 'generation-1' }) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, @@ -80,36 +79,36 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -it('a child that proves its start at acquire tells the catalog, with its record', async () => { - startHost('ready') +/** 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' }, + restoreSkippedOptions: [], + optionRevision: 0 + }) +} + +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('ready') + 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) }) - -it('a published child tells the catalog only once its start is proven', async () => { - startHost('starting') - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) - expect(providerStarted).not.toHaveBeenCalled() - await host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: 0 - }) - expect(providerStarted).toHaveBeenCalledTimes(1) -}) 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 c25df2408bc3..f8c8d7275591 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,15 +1,20 @@ // The host's half of a provider child proving its start. // -// A publish-first acquire hands the host a child that has answered nothing yet, so the record -// keeps only the saved options the reservation carried, and the delivery loop hands it nothing. -// This is where the host learns the start landed: the child turns `ready`, its startup attempt -// ends, and the loop wakes to hand over what was queued meanwhile. Only once that handover is done -// is what the child reports persisted, in a step of its own, so bookkeeping never sits between a -// ready child and the user's first message; a failed write is reported, never thrown. Every report -// is persisted only if no pick or later report came after its read (`option-revisions`). +// Every acquire hands the host a child that has answered nothing yet, so the record keeps only the +// saved options the reservation carried, and the delivery loop hands it nothing. This is where the +// host learns the start landed: the provider handle the child answered with is recorded (a resume +// needs it), picks made meanwhile are applied, a rewind left in doubt is settled, the child turns +// `ready`, its startup attempt ends, and the loop wakes to hand over what was queued. A handle that +// cannot be recorded, or a rewind that stays in doubt, fails the start; a start whose limit passed +// first is never accepted. +// Only once that handover is done is what the child reports persisted, in a step of its own, so +// bookkeeping never sits between a ready child and the user's first message; a failed write is +// reported, never thrown. Every report is persisted only if no pick or later report came after its +// read (`option-revisions`). // -// These run under the session's own serialized steps, which its close and sends wait on, so they -// ask the provider nothing: the event carries what the child proved. +// These run under the session's own serialized steps, which its close and sends wait on, so the +// only provider calls here are the picks the user made while the child started and the rewind +// recovery, both bounded by the startup limit or the adapter's own request timeout. import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { @@ -19,15 +24,25 @@ import type { } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' import { markProviderChildStarted, - sameProviderChild + sameProviderChild, + structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' 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 { + applyStructuredAgentSessionStartupIntent, + revertRefusedStartupIntent +} from './structured-agent-session-startup-intent' +import { recoverStructuredRewind } from './structured-rewind-recovery' export type StructuredAgentSessionProviderStartedContext = { deps: StructuredAgentSessionHostDeps @@ -36,7 +51,8 @@ export type StructuredAgentSessionProviderStartedContext = { now: () => number publishStatus?: (sessionId: string) => void runtimeState: { - startupAttempts: Pick + startupAttempts: Pick + acquireAborts: Pick optionRevisions: Pick< StructuredAgentSessionOptionRevisions, 'admitReport' | 'advance' | 'isNewest' @@ -45,6 +61,8 @@ export type StructuredAgentSessionProviderStartedContext = { /** The barrier lifts: what was accepted while the child started is handed over now. Settles once * the loop has handed over all it can. */ wakeDelivery: (sessionId: string) => Promise + /** Ends a starting child whose start cannot be completed, as its startup limit would. */ + stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void } type ReportedOptions = Omit @@ -59,8 +77,32 @@ export async function settleStructuredAgentSessionProviderStarted( const started = await context.serialize(event.sessionId, async () => { const session = context.sessions.get(event.sessionId) const child = { generation: event.acquisitionGeneration, fence: event.fence } - // A stale child's proof starts nothing: the barrier stays on the child the host holds. - if (!session || !markProviderChildStarted(session, child)) { + // A stale child's proof starts nothing: the barrier stays on the child the host holds. Nor + // does one that lost the race to its startup limit: the limit's stop is queued behind this. + if ( + !session?.child || + session.child.phase !== 'starting' || + !sameProviderChild(session.child, child) || + !context.runtimeState.startupAttempts.onClock(event.sessionId, child) + ) { + return null + } + if (!(await recordStartedHandle(context, event))) { + context.stopStartingChild(event.sessionId, child) + return null + } + if (!(await applyPicksMadeWhileStarting(context, event, session.child))) { + return null + } + if (!(await recoverRewindAtStart(context, event.sessionId, session))) { + context.stopStartingChild(event.sessionId, child) + return null + } + // The limit can pass while the picks or the recovery ran; its stop ends this child next. + if ( + !context.runtimeState.startupAttempts.onClock(event.sessionId, child) || + !markProviderChildStarted(session, child) + ) { return null } context.runtimeState.startupAttempts.ready(event.sessionId, child) @@ -79,6 +121,93 @@ export async function settleStructuredAgentSessionProviderStarted( void started.delivered.then(() => persistReportedOptions(context, event, admitted)) } +/** Picks made while the child started reach it before it is accepted, under its startup clock. + * False when a close, Stop, quit or the limit cut them short: whoever did ends the child, which + * never runs a value the chat does not show. A pick it refused is shown as what it runs. */ +async function applyPicksMadeWhileStarting( + context: StructuredAgentSessionProviderStartedContext, + event: StructuredAgentSessionStartedEvent, + child: StructuredAgentSessionProviderChild +): Promise { + const launched = child.launchedOptions ?? {} + const applied = await applyStructuredAgentSessionStartupIntent( + { + deps: context.deps, + acquireAborts: context.runtimeState.acquireAborts, + optionRevisions: context.runtimeState.optionRevisions + }, + event.sessionId, + child, + launched + ) + if (applied.aborted) { + return false + } + await revertRefusedStartupIntent(context, { + sessionId: event.sessionId, + fence: child.fence, + refused: applied.failed, + launched, + reported: event.reportedOptions + }) + return true +} + +/** 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( + context: StructuredAgentSessionProviderStartedContext, + sessionId: string, + session: StructuredAgentSessionHostSession +): Promise { + try { + await recoverStructuredRewind( + context.deps, + sessionId, + session.journal, + structuredAgentSessionConversationFence(context.deps.store, sessionId), + context.deps.adapter, + context.now + ) + return true + } catch (error) { + context.deps.logger.warn('settling a rewind in doubt at the start failed', { + scope: 'rewind-recovery', + sessionId, + error + }) + return false + } +} + +/** 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 +): 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. + return store.getRecord(event.sessionId)?.lease.provenHandleLinkId != null + } + try { + await store.transitionHandoff(event.sessionId, (record) => + recordAgentSessionProviderHandle({ record, fence: event.fence, link, now: context.now() }) + ) + return true + } catch (error) { + logger.warn('recording the handle a started provider answered with failed', { + scope: 'provider-started-handle', + sessionId: event.sessionId, + error + }) + return false + } +} + /** A proven start shows the agent's program exists and may mean a sign-in was fixed, so the * catalog's held reason is re-checked by the next read's probe sooner; the probe decides. */ export function noteStructuredAgentSessionProviderStarted( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 0feaf809f2a5..532a6b162c4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -85,6 +85,11 @@ function derivedPauses(): string[] { return structuredQueuePauses(journal).map((pause) => pause.reason) } +/** The Stop's next step on the session's lane, which ends the child, has run. */ +function laneDrained(): Promise { + return rig.host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) +} + /** Lets the drain run its steps; none may hand a card off. */ async function expectNothingSent(...draftIds: string[]): Promise { await new Promise((resolve) => setTimeout(resolve, 250)) @@ -187,15 +192,18 @@ function expectOneWorkingRun(views: readonly ClientView[]): void { } describe('a Stop holds the queue, in order', () => { - it('a card typed while the Stop lands waits behind the held one; Resume sends both in order, with no Resume or Send between them', async () => { + it('while the stopped turn winds down the paused row shows without Resume; Resume then sends the held cards in order, with no Resume or Send between them', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ restartable: true, windsDown: true }) const working = await rig.workingSend() const held = await queuedDraft('held by the stop') + const typed = await queuedDraft('queued behind it') await rig.stop() - const typed = await queuedDraft('typed while the stop lands') const views = await watchClient() // Still winding down: the paused row shows, but neither Resume nor "Send message?" while it runs. expect(views.at(-1)).toMatchObject({ working: true, header: true, dialog: false }) await rig.settleAccepted(working, 'stopped') + await laneDrained() await eventually(() => expect(views.at(-1)).toMatchObject({ header: true, dialog: true })) expect(views.at(-1)?.button).toBe('resume') await expectNothingSent(held, typed) @@ -215,6 +223,7 @@ describe('a Stop holds the queue, in order', () => { await queuedDraft('second') await rig.stop() await rig.settleAccepted(working, 'stopped') + await laneDrained() const views = await watchClient() const before = views.length expect(views.at(-1)).toMatchObject({ working: false, button: 'resume' }) @@ -435,6 +444,7 @@ describe("a message sent over a held queue (the confirmation's Send message)", ( const second = await queuedDraft('second') await rig.stop() await rig.settleAccepted(working, 'stopped') + await laneDrained() const views = await watchClient() expect(views.at(-1)).toMatchObject({ header: true, button: 'resume', dialog: true, cards: 2 }) return { first, second, views } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts index ee7a26af161e..d2abfd694232 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts @@ -26,7 +26,8 @@ import { let rig: QueuedMessageTestRig beforeEach(async () => { - rig = await createQueuedMessageTestRig() + // A Stop ends the agent, so the card's run resumes the conversation in a new one. + rig = await createQueuedMessageTestRig({ restartable: true }) }) afterEach(() => rig.dispose()) 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 86fff5a7b973..52532a3ff06b 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 @@ -5,9 +5,12 @@ import { vi, type Mock } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { activeProviderContext } from '../../../shared/agent-session-provider-context' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' import { HOST_TEST_NOW as NOW, HOST_TEST_THREAD as THREAD @@ -19,17 +22,20 @@ export type QueuedRigProviderOptions = { /** A child started for a chat whose chain already names a thread resumes it, so a chat whose * child closed or died can start another. */ restartable?: true - /** Every child stays starting. */ + /** Every child stays starting: it never answers its start, so it is handed nothing. */ starting?: true - /** The provider's Stop ends its child, as Claude's does. */ - stopEndsSession?: true - /** Its child never answers its start, so it runs nothing it is handed. */ - startUnanswered?: true + /** A Stop's end of the child waits, as every shipping adapter's does, for the work it stopped + * to end (no running turn, no unanswered send), or for the grace to run out. */ + windsDown?: true } +/** How long a winding-down child is given after the interrupt before the Stop ends it. */ +export const QUEUED_RIG_STOP_GRACE_MS = 2_000 + export function createQueuedRigProvider( store: Pick, - options: QueuedRigProviderOptions + options: QueuedRigProviderOptions, + host: () => StructuredAgentSessionHost ) { // Admitted: the message is written and unanswered, so the session owes work // until the test settles it. @@ -53,7 +59,7 @@ export function createQueuedRigProvider( ) let events: StructuredAgentSessionEventSink | undefined - const adapter: StructuredAgentSessionAdapter = { + const scripted: StructuredAgentSessionAdapter = { acquire: async ({ identity, fence, spawnToken, events: sink }) => { starts() const hold = startHold @@ -79,7 +85,6 @@ export function createQueuedRigProvider( spawnToken }, acquisitionGeneration: 'generation-1', - ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), link: { linkId: `link-${fence}`, handle: codexProviderHandle(thread), @@ -94,16 +99,37 @@ export function createQueuedRigProvider( releaseAcquisition: vi.fn(async () => true), compact, cancelTurn, - ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), - ...(options.startUnanswered ? { startAnswered: () => false } : {}), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), ...(options.rewind ? { rewind: options.rewind, rewindSupport: () => ({ supported: true as const }) } : {}), - ...(options.recoverRewind ? { recoverRewind: options.recoverRewind } : {}) + ...(options.recoverRewind ? { recoverRewind: options.recoverRewind } : {}), + ...(options.windsDown + ? { + awaitStoppedRequestEnd: async (sessionId: string, stoppedAt: number) => { + while (Date.now() < stoppedAt + QUEUED_RIG_STOP_GRACE_MS && working(sessionId)) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + } + } + : {}) + } + + function working(sessionId: string): boolean { + const journal = host().collaboratorsForTests().sessions.get(sessionId)?.journal + return ( + journal !== undefined && + isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + store.getRecord(sessionId)?.lease.runtimeFence + ) + ) } + const adapter = options.starting ? scripted : startsWhenPublished(scripted, host) + /** What the provider's translator writes when a /compact's turn ends, as a success. */ function finishCompact(): void { const { command } = compact.mock.calls.at(-1)![0] 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 ef7e36953bd3..41fc7807bcc4 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 @@ -54,7 +54,7 @@ export async function createQueuedMessageTestRig( const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) - const provider = createQueuedRigProvider(store, options) + const provider = createQueuedRigProvider(store, options, () => host) const { dispatch, compact, cancelTurn, closeSession } = provider const makeHost = () => new StructuredAgentSessionHost({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 38fbd11f3146..1c1c668fe4ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -39,7 +39,8 @@ let store: QueuedMessageTestRig['store'] let dispatch: QueuedMessageTestRig['dispatch'] beforeEach(async () => { - rig = await createQueuedMessageTestRig() + // A Stop ends the agent's session, so the next send starts a child that resumes the thread. + rig = await createQueuedMessageTestRig({ restartable: true }) ;({ host, store, dispatch } = rig) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 418c51f5cc74..c3d2ae90b152 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -25,7 +25,7 @@ import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig beforeEach(async () => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true }) }) afterEach(() => rig.dispose()) @@ -65,6 +65,11 @@ async function stoppedDraft(): Promise { return draftId } +/** The Stop's next step on the session's lane, which ends the child, has run. */ +function laneDrained(): Promise { + return rig.host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) +} + /** A queued draft handed off and handed over, found by its hand-off link. */ async function handedOver(draftId: string): Promise { await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) @@ -119,12 +124,15 @@ describe("a Stop's queue pause", () => { await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => { - const working = await rig.workingSend() + it("a draft typed after the Stop waits with the rest: the pause is the queue's", async () => { + await rig.workingSend() const olderId = await queuedDraft('paused by the stop') await rig.stop() - const typedId = await queuedDraft('typed while stopping') - await rig.settleAccepted(working, 'stopped') + await laneDrained() + // Queued behind a send made after the Stop, which the agent then refuses: no turn started. + const refused = await handedOverUserSend('sent after the stop') + const typedId = await queuedDraft('typed while that send waits') + await rig.settleRejected(refused, 'turn/start refused') await expectPaused(olderId, typedId) expect(await rig.drafts()).toEqual([ { messageId: olderId, state: 'waiting' }, @@ -225,12 +233,14 @@ describe('the pause read', () => { }) describe('a card queued after a Stop is a new instruction', () => { - it('a correction typed after a Stop over an empty queue sends when the stopped turn ends', async () => { - const working = await rig.workingSend() + it('a correction typed after a Stop over an empty queue goes straight to the next agent', async () => { + await rig.workingSend() await rig.stop() - const correction = await queuedDraft('typed right after the stop') - await rig.settleAccepted(working, 'stopped') - await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) + // Sent behind the Stop's end of the child, so nothing is left running to queue it behind. + const correction = await rig.send('typed right after the stop', 'queue-if-active').result + expect(correction).toMatchObject({ ok: true, value: { submission: { fence: 2 } } }) + expect(await rig.drafts()).toEqual([]) + expect(await rig.queuePause()).toBeNull() }) it('a card sent now before the Stop and taken anyway lifts nothing, and holds nothing typed later', async () => { @@ -431,19 +441,26 @@ describe('Resume', () => { }) it('is idempotent: a replay of the same Resume answers without lifting a later pause', async () => { - const working = await rig.workingSend() + await rig.workingSend() const draftId = await queuedDraft('paused twice') await rig.stop() const operationId = hostTestOperationId() expect(await rig.resume(operationId)).toMatchObject({ ok: true, value: { resumed: true } }) + await handedOver(draftId) + // The second Stop takes the card's unanswered send back, and it waits again under that Stop. await rig.stop() + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + ) + const withdrawn = await rig.handoffId(draftId) expect(await rig.resume(operationId)).toMatchObject({ ok: true, replayed: true, value: { resumed: false } }) - await rig.settleAccepted(working, 'stopped') - await expectPaused(draftId) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoffId(draftId)).toBe(withdrawn) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) it("of a Stop from before a restart, which no client offers there, also lifts the restart's pause", async () => { @@ -486,7 +503,8 @@ describe('a failed Stop', () => { }) it('keeps its pause when it fails after the interrupt reached the agent', async () => { - await rig.workingSend() + // A running turn, so the Stop writes its note in its own step rather than at the child's end. + await openRigTurnFor(rig, await rig.workingSend()) const draftId = await queuedDraft('paused by stop') const append = AgentSessionJournal.prototype.appendItem const failing = vi diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index e6ecad009d4c..211b5333ed6a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -62,6 +62,11 @@ async function mailTurn(): Promise { return mail.id } +/** The Stop's next step on the session's lane, which ends the child, has run. */ +function laneDrained(): Promise { + return rig.host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) +} + function withdraw(clientMessageId: string) { return rig.host.settleLateDispatch({ sessionId: HOST_TEST_SESSION, @@ -107,11 +112,19 @@ describe("Stop's event", () => { }) it('a card queued after the Stop waits behind the cards it holds, then all send in order', async () => { - const working = await rig.workingSend() + await rig.workingSend() const held = await queuedDraft('queued before the stop') await rig.stop() - const later = await queuedDraft('typed while the stopped turn winds down') - await rig.settleAccepted(working, 'stopped') + await laneDrained() + // A send after the Stop reaches the next agent; a card typed while it waits queues behind it. + const refused = rig.send('sent after the stop') + await refused.result + await eventually(async () => + expect((await rig.submission(refused.id))?.handedOverAt).toBeDefined() + ) + const later = await queuedDraft('typed while that send waits') + // No turn sent after the Stop started, so the pause holds both cards. + await rig.settleRejected(refused.id, 'turn/start refused') // The queue never reorders: the newer card waits behind the held one, with no caption of its own. await expectHeld('stopped', held, later) expect(await rig.drafts()).toEqual([ @@ -175,8 +188,10 @@ describe("Stop's event", () => { const waiting = await queuedDraft('waiting behind it') await rig.sendNow(sentId) await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + // The process dies after the Stop answered, before its next step ended the child. + rig.closeSession.mockImplementationOnce(() => new Promise(() => {})) await rig.stop() - // The process dies with no close; a new host opens the same state directory. + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) rig.crashRestartHostProcess() expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') // After a restart no row shows, the Stop's included, and nothing sends. @@ -211,10 +226,17 @@ describe("a restart's hold over a card queued after a Stop", () => { }) it("a card queued during the queue's own send after a Stop, before a restart, waits unshown", async () => { - const working = await rig.workingSend() + await rig.workingSend() await rig.stop() - const correction = await queuedDraft('typed while the interrupt lands') - await rig.settleAccepted(working, 'stopped') + await laneDrained() + // Sent after the Stop, so its turn starting ends the pause, and the card behind it drains. + const next = rig.send('sent after the stop') + await next.result + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + const correction = await queuedDraft('typed while that send waits') + await rig.settleAccepted(next.id, 'next') await eventually(async () => expect((await rig.handoff(correction))?.handedOverAt).toBeDefined() ) 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 index 2a201e94ef0a..d3018993a28d 100644 --- 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 @@ -31,6 +31,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 { startsWhenPublished } from './structured-agent-session-instant-start.test-support' import { AgentSessionJournal } from '../agent-session-journal/journal-store' const CALLER = { callerKey: 'client-1' } @@ -64,32 +65,35 @@ 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 = { - acquire: async ({ fence }) => ({ - 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 - } + const adapter: StructuredAgentSessionAdapter = startsWhenPublished( + { + acquire: async ({ fence }) => ({ + 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: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), 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 index 24546a97bcc0..de186de3487b 100644 --- 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 @@ -25,6 +25,7 @@ import { } 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' } @@ -44,27 +45,30 @@ beforeEach(async () => { stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) cancelTurn = vi.fn(async () => ({ cancelled: true })) store = await openTestAgentSessionRecordStore(root) - const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence, spawnToken }) => ({ - 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 - } + const adapter: StructuredAgentSessionAdapter = startsWhenPublished( + { + acquire: async ({ fence, spawnToken }) => ({ + 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(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts index 7b85104b8a52..36e9027f3db4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts @@ -2,11 +2,13 @@ // Stop, however late and from whichever client, rewrites that row or writes nothing. import { beforeEach, describe, expect, it, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { AgentJournalTurnLifecycleState } from '../../../shared/agent-session-journal-types' 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 { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { @@ -82,13 +84,19 @@ describe('a Stop pressed again', () => { expect(await statusRows()).toEqual([REQUESTED]) }) - it('naming no turn, rewrites the row of the turn running, as a Stop naming it does', async () => { + it('naming no turn, keys its row by the turn running, as a Stop naming it does', async () => { await attach() await turn('running') const unnamed = () => host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) - expect(await unnamed()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await unnamed()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await stopTurn()).toMatchObject({ ok: true, value: { cancelled: true } }) + const [first, second, named] = await Promise.all([unnamed(), unnamed(), stopTurn()]) + + expect(first).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(second).toMatchObject({ ok: true }) + expect(named).toMatchObject({ ok: true }) + const { items } = await host.journalSnapshot(SESSION) + expect(items.flatMap((item) => (item.body.kind === 'status' ? [item.itemId] : []))).toEqual([ + agentJournalItemKey(structuredAgentSessionStopNoteIdentity('turn-1')) + ]) expect(await statusRows()).toEqual([REQUESTED]) }) 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 639cdc9b3ae7..21e35eebb9a8 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 @@ -22,6 +22,7 @@ import type { } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { HOST_TEST_NOW, @@ -66,6 +67,11 @@ 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 + * published. */ + heldStart: ( + acquire: StructuredAgentSessionAdapter['acquire'] + ) => StructuredAgentSessionAdapter['acquire'] /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ restart: (deps?: Partial) => Promise dispose: () => Promise @@ -163,18 +169,19 @@ export async function createRestTestRig( startUnavailable: vi.fn(() => undefined), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } + const starts = structuredAgentSessionHeldStarts(() => rig.host) const hostFor = (overrides: Partial) => new StructuredAgentSessionHost({ agents: claudeAndCodexAgents(), logger: createStructuredAgentSessionLogger(), store, - adapter: { + adapter: starts.wrap({ ...adapter, releaseAcquisition: vi.fn(async () => true), cancelTurn: async () => ({ cancelled: true }), answerPrompt: async ({ commit }) => commit(), setOption: async () => undefined - }, + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', @@ -193,6 +200,7 @@ export async function createRestTestRig( clock, statusEvents, sink, + heldStart: starts.held, 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-context-clear.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts index 09e48d3a3ef2..d52763257607 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts @@ -58,7 +58,7 @@ describe('restart offers after context clear', () => { sessionId: SESSION, session: { journal: journal([turnItem('old', 'running'), clear], [unknown]), - child: { fence: 2 } + child: { fence: 2, phase: 'ready' } }, getRecord: value, childWork: () => undefined, @@ -78,7 +78,7 @@ describe('restart offers after context clear', () => { const turn = { ...turnItem('new-turn', 'running'), sequence: 6 } const offer = structuredAgentSessionWorkingAtStop({ sessionId: SESSION, - session: { journal: journal([clear, turn]), child: { fence: 2 } }, + session: { journal: journal([clear, turn]), child: { fence: 2, phase: 'ready' } }, getRecord: () => currentRecord, childWork: () => undefined, trigger: 'quit', 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 e1a4e14cd0d9..d26f99105a54 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 @@ -69,7 +69,6 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, childWork: surfaces.readChildWork, - 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-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index bc88fe1c4152..1f0af01a1c12 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -51,7 +51,13 @@ describe('deriving what was working at teardown', () => { it('marks a session this host was running a turn for', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'running')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => undefined, @@ -78,7 +84,13 @@ describe('deriving what was working at teardown', () => { it('carries the update trigger so the surface can say the restart was not the user choice', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'running')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => undefined, @@ -93,7 +105,9 @@ describe('deriving what was working at teardown', () => { it('marks nothing for an idle session', () => { expect( markersAtTeardown({ - sessions: new Map([[SESSION, { journal: journal([]), child: { fence: 1 } }]]), + sessions: new Map([ + [SESSION, { journal: journal([]), child: { fence: 1, phase: 'ready' as const } }] + ]), getRecord: () => record(), childWork: () => undefined, trigger: 'quit', @@ -107,7 +121,13 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => undefined, @@ -145,7 +165,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-1', 'running'), pendingApproval()]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -169,7 +189,13 @@ describe('deriving what was working at teardown', () => { it('marks a settled lead whose subagent was still running, anchored on its last turn', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => [childRecord({ id: 'task-a', kind: 'agent', description: 'Review loop 4' })], @@ -190,7 +216,13 @@ describe('deriving what was working at teardown', () => { it('records only the live rows of the roster, bounded', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => [ @@ -216,7 +248,13 @@ describe('deriving what was working at teardown', () => { it('marks a settled lead whose only live work is a monitor', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => [ @@ -235,7 +273,13 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'completed')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record(), childWork: () => [ @@ -253,7 +297,13 @@ describe('deriving what was working at teardown', () => { it('records the identity root so an advancing Claude leaf cannot invalidate the marker', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'running')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => claudeRecord(null), childWork: () => undefined, @@ -269,7 +319,13 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] + [ + SESSION, + { + journal: journal([turnItem('turn-1', 'running')]), + child: { fence: 1, phase: 'ready' as const } + } + ] ]), getRecord: () => record({ chain: [] }), childWork: () => undefined, @@ -290,7 +346,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([], [submission('msg-1', 'pending')]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -313,7 +369,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-0', 'completed')], [submission('msg-1', 'pending')]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -338,7 +394,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-0', 'completed')], [queued]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -364,14 +420,14 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-0', 'completed')], [handedOver, queued]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ], [ 'session-running', { journal: journal([turnItem('turn-1', 'running')], [queued]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), @@ -397,7 +453,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-1', 'running')], [submission('msg-1', 'accepted')]), - child: { fence: 1 } + child: { fence: 1, phase: 'ready' as const } } ] ]), 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 index 335d5a14686a..c585fb2a3c52 100644 --- 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 @@ -30,6 +30,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 { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -59,11 +60,11 @@ function adapter(): StructuredAgentSessionAdapter { } function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { - const host = new StructuredAgentSessionHost({ + const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: adapter(), + adapter: startsWhenPublished(adapter(), () => host), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', 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 dc71bc09f071..739ad2c5a33f 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 @@ -20,7 +20,6 @@ function witnessed() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only the record's provider. AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, 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 648f92b6d595..683fe79d9e87 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 @@ -33,7 +33,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null childWork: WorkingAtStopInput['childWork'] - startAnswered: NonNullable capsule?: Pick teardownId: string now: () => number @@ -73,7 +72,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { session: deps.sessions.get(sessionId), getRecord: deps.getRecord, childWork: deps.childWork, - 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 6776a2f9e898..03e6a399cecd 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 @@ -29,6 +29,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 { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -44,39 +45,42 @@ const rewindSupport = vi.fn() function adapter(): StructuredAgentSessionAdapter { - return { - supportsCreate: (_location, agent) => agent === 'codex', - supportsLocation: () => true, - acquire: async (input) => { - acquires += 1 - sink = input.events! - return { - process: { - hostId: 'local', - pid: 4000 + acquires, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - acquisitionGeneration: `generation-${acquires}`, - link: { - linkId: `link-${acquires}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: acquires === 1 ? 'created' : 'resumed', - handle: codexProviderHandle(THREAD) + return startsWhenPublished( + { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires += 1 + sink = input.events! + return { + process: { + hostId: 'local', + pid: 4000 + acquires, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires}`, + link: { + linkId: `link-${acquires}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires === 1 ? 'created' : 'resumed', + handle: codexProviderHandle(THREAD) + } } - } + }, + dispatch, + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport, + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true }, - dispatch, - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => {}, - setOption: async () => {}, - rewindSupport, - rewind, - recoverRewind, - releaseAcquisition: async () => true, - closeSession: async () => true - } + () => host + ) } function openHost(): StructuredAgentSessionHost { 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 85247148f863..4ba833a93f77 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 @@ -32,6 +32,7 @@ import { } 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 { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -47,39 +48,42 @@ const rewindSupport = vi.fn() function adapter(): StructuredAgentSessionAdapter { - return { - supportsCreate: (_location, agent) => agent === 'codex', - supportsLocation: () => true, - acquire: async (input) => { - acquires += 1 - sink = input.events! - return { - process: { - hostId: 'local', - pid: 4000 + acquires, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - acquisitionGeneration: `generation-${acquires}`, - link: { - linkId: `link-${acquires}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: acquires === 1 ? 'created' : 'resumed', - handle: codexProviderHandle(THREAD) + return startsWhenPublished( + { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires += 1 + sink = input.events! + return { + process: { + hostId: 'local', + pid: 4000 + acquires, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires}`, + link: { + linkId: `link-${acquires}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires === 1 ? 'created' : 'resumed', + handle: codexProviderHandle(THREAD) + } } - } + }, + dispatch, + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport, + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true }, - dispatch, - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => {}, - setOption: async () => {}, - rewindSupport, - rewind, - recoverRewind, - releaseAcquisition: async () => true, - closeSession: async () => true - } + () => host + ) } function openHost(): StructuredAgentSessionHost { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts index f3664c3166f2..bba0e9321261 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts @@ -100,6 +100,8 @@ export async function expectRawStopNoteRewindRecovery({ hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) + // A restarted child settles the rewind in its start, which runs after the attach. + await host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(journal.itemBody(noteId)).toEqual(unconfirmed) expect(journal.snapshot().items.find((item) => item.itemId === noteId)?.body).toEqual({ 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 1bbb02777243..fc5ec2684674 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 @@ -40,6 +40,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 { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' const caller = { callerKey: 'desktop' } let clock = HOST_TEST_NOW @@ -68,42 +69,45 @@ beforeEach(async () => { acquires = [] directory = await mkdtemp(join(tmpdir(), 'orca-rewind-')) store = await openTestAgentSessionRecordStore(directory) - adapter = { - supportsCreate: (_location, agent) => agent === 'codex', - supportsLocation: () => true, - acquire: async (input) => { - acquires.push(input) - sink = input.events! - return { - process: { - hostId: 'local', - pid: 4000 + acquires.length, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - acquisitionGeneration: `generation-${acquires.length}`, - link: { - linkId: `link-${acquires.length}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: acquires.length === 1 ? 'created' : 'resumed', - handle: codexProviderHandle(HOST_TEST_THREAD) + adapter = startsWhenPublished( + { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires.push(input) + sink = input.events! + return { + process: { + hostId: 'local', + pid: 4000 + acquires.length, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires.length}`, + link: { + linkId: `link-${acquires.length}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires.length === 1 ? 'created' : 'resumed', + handle: codexProviderHandle(HOST_TEST_THREAD) + } } - } + }, + dispatch: vi.fn(async (): Promise => ({ + state: 'unknown', + reason: 'test' + })), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport: () => ({ supported: true }), + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true }, - dispatch: vi.fn(async (): Promise => ({ - state: 'unknown', - reason: 'test' - })), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => {}, - setOption: async () => {}, - rewindSupport: () => ({ supported: true }), - rewind, - recoverRewind, - releaseAcquisition: async () => true, - closeSession: async () => true - } + () => host + ) host = new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), @@ -120,6 +124,20 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) +/** Attaches, then lets the child it started prove its start, where a rewind left in doubt is + * settled; a start that cannot settle it is stopped in a step of its own. */ +async function attachAndStart(fence: number | null) { + const attached = await host.attach(caller, hostTestAttachParams(fence)) + const { serialize } = host.collaboratorsForTests() + await serialize(HOST_TEST_SESSION, async () => {}) + await serialize(HOST_TEST_SESSION, async () => {}) + return attached +} + +function startedChild() { + return host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null +} + async function seed(acceptedSubmissions = false) { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const keys = ['kept', 'drop', 'tip'].map((turnId) => ({ @@ -219,7 +237,7 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded') replace.mockRestore() const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await attachAndStart(fence)).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) @@ -236,18 +254,14 @@ describe('host rewind', () => { expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockRejectedValueOnce(new Error('history still unavailable')) - await expect( - host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).rejects.toThrow('history still unavailable') + // The restarted child's start cannot settle it, so that start fails and nothing is handed over. + expect( + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ).toMatchObject({ ok: true }) + expect(startedChild()).toBeNull() expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared') expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items[0]?.body).toEqual( @@ -397,10 +411,7 @@ describe('host rewind', () => { }) expect(adapter.dispatch).not.toHaveBeenCalled() expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) @@ -414,10 +425,7 @@ describe('host rewind', () => { await expect(host.rewind(caller, await params(target))).rejects.toThrow('read failed') recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') @@ -435,10 +443,7 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') @@ -720,10 +725,7 @@ describe('host rewind', () => { items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] }) expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect( @@ -751,10 +753,7 @@ describe('host rewind', () => { await expect(host.rewind(caller, await params(target))).rejects.toThrow('lost after revert') recoverRewind.mockResolvedValueOnce({ ok: true, items }) expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(2) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') @@ -784,12 +783,10 @@ describe('host rewind', () => { items: missing === 'turn' ? [] : items.slice(0, 1) }) const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') - await expect( - host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).rejects.toThrow('proof-mismatch') + expect( + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ).toMatchObject({ ok: true }) + expect(startedChild()).toBeNull() expect(replace).not.toHaveBeenCalled() replace.mockRestore() expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.expectedEpoch).toBe(before.cursor.epoch) @@ -818,10 +815,7 @@ describe('host rewind', () => { checkpoint.mockRestore() const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') expect( - await host.attach( - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) + await attachAndStart(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) expect(replace).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index bd819d09f2ca..8c92ddd51246 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -20,12 +20,25 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import { conversationCommandBlocked } from './structured-conversation-command-admission' -import { rewindRefusal } from './structured-rewind-refusal' +import { rewindRefusal, rewindRefusalBehindClear } from './structured-rewind-refusal' import { renameRewindTurnOpener } from './structured-rewind-journal-body' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' +import { runAfterProviderStart } from './structured-agent-session-provider-start-hold' -export async function rewindStructuredAgentSession( +export function rewindStructuredAgentSession( + context: StructuredAgentSessionMutationContext, + attachContext: StructuredAgentSessionAttachContext, + caller: StructuredAgentSessionCaller, + params: AgentSessionRewindParams +): Promise> { + // Only a proven child has the protocol session a rewind reads and changes. + return runAfterProviderStart(context, params.envelope.sessionId, () => + rewindUnderSerialize(context, attachContext, caller, params) + ) +} + +function rewindUnderSerialize( context: StructuredAgentSessionMutationContext, attachContext: StructuredAgentSessionAttachContext, caller: StructuredAgentSessionCaller, @@ -43,20 +56,9 @@ export async function rewindStructuredAgentSession( callerKey: caller.callerKey, envelope: params.envelope, // Only the provider can do this, so an agent at rest is started first. - prepareSession: openWithAgent(context, params.envelope, () => { - const journal = context.sessions.get(sessionId)?.journal - const floor = journal?.context.floor() - if (!journal || !floor) { - return { ok: true } - } - if (journal.cursor().epoch !== params.expectedEpoch) { - return rewindRefusal('stale-epoch') - } - const target = journal.snapshot().items.find((item) => item.itemId === params.itemId) - return !target || target.sequence <= floor.sequence - ? rewindRefusal('invalid-target') - : { ok: true } - }), + prepareSession: openWithAgent(context, params.envelope, () => + rewindRefusalBehindClear(context.sessions.get(sessionId)?.journal, params) + ), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), now: context.now, 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 index 4ceae6b6c047..930c0fd1f334 100644 --- 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 @@ -27,6 +27,7 @@ import { 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' } @@ -79,15 +80,18 @@ beforeEach(async () => { error: (_message, fields) => hostErrors.push(fields.error) }, store, - adapter: { - 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) - }, + 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}`, @@ -688,7 +692,8 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(await submission(firstParams.envelope.clientOperationId)).toMatchObject({ dispatchState: 'rejected' }) - expect(acquire).toHaveBeenCalledOnce() + // 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 4aac5417b656..78e430dfa7fc 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 @@ -25,6 +25,7 @@ import { import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { rewindRefusal } from './structured-rewind-refusal' import { recoverStructuredRewind } from './structured-rewind-recovery' +import { refuseWhileProviderStarting } from './structured-agent-session-provider-start-hold' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { conversationCommandInFlight } from './structured-conversation-command-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' @@ -96,11 +97,16 @@ export function openForProviderWrite( } } -/** For an operation only the provider can perform: the conversation, then its agent. */ +/** For an operation only the provider can perform: the conversation, then its agent, proven + * started; the caller waits out a start under way (`runAfterProviderStart`). `beforeAgent` + * answers what needs no agent before one is started for it. */ export function openWithAgent( - context: Pick, + context: Pick< + StructuredAgentSessionMutationContext, + 'openConversation' | 'ensureAgent' | 'deps' | 'sessions' + >, envelope: AgentSessionMutationEnvelope, - beforeAgent?: () => AgentSessionMutationSessionPreparation + beforeAgent?: (record: AgentSessionRecord | null) => AgentSessionMutationSessionPreparation | null ): (ledger: 'admit' | 'replay') => Promise { return async (ledger) => { const opened = await openConversationForWrite( @@ -111,13 +117,19 @@ export function openWithAgent( if (!opened.ok || ledger === 'replay') { return opened } - const checked = beforeAgent?.() - return checked && !checked.ok ? checked : context.ensureAgent(envelope.sessionId) + const checked = beforeAgent?.(context.deps.store.getRecord(envelope.sessionId)) + if (checked && !checked.ok) { + return checked + } + const ensured = await context.ensureAgent(envelope.sessionId) + return ensured.ok + ? (refuseWhileProviderStarting(context.sessions.get(envelope.sessionId)) ?? ensured) + : ensured } } -/** The recovery an attach runs, for a child already running: no attach comes for it. A recovery - * that stays unknown leaves the record as it was. */ +/** The recovery a proven start runs, for a child already running: no start comes for it. A + * recovery that stays unknown leaves the record as it was. */ async function recoverRewindOnLiveChild( context: Pick, sessionId: string @@ -148,8 +160,9 @@ async function recoverRewindOnLiveChild( /** A rewind still in doubt once the conversation is open is one only its provider can settle — * the open settles every other — so a send settles it first: an agent at rest is started, whose - * attach recovers it, and a running one is asked as that attach would. One still in doubt refuses - * the send here, before the ledger records it, so a Retry of the same id is decided afresh. A + * proven start recovers it while the send waits that start out (`runAfterProviderStart`), and a + * running one is asked as that start would. One still in doubt refuses the send here, before the + * ledger records it, so a Retry of the same id is decided afresh. A * resend of a recorded id needs only the conversation, its answer's source: it starts nothing, * and an open that fails leaves that answer unknown, never refused. `clearInFlight`: a /clear was * running when this send arrived, which refuses only its first run. `refusesInRun`: the caller's @@ -181,14 +194,15 @@ export function sendPreparation( if (!opened.ok || !rewindInDoubt(context.deps.store.getRecord(sessionId))) { return opened } - const running = Boolean(context.sessions.get(sessionId)?.child) const ensured = await context.ensureAgent(sessionId) if (!ensured.ok) { return ensured } - if (running) { - await recoverRewindOnLiveChild(context, sessionId) + const starting = refuseWhileProviderStarting(context.sessions.get(sessionId)) + if (starting) { + return starting } + await recoverRewindOnLiveChild(context, sessionId) return !arrival.refusesInRun && rewindInDoubt(context.deps.store.getRecord(sessionId)) ? rewindRefusal('outcome-unknown') : ensured 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 fb2f9beae728..4e9d1503e820 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 @@ -151,8 +151,7 @@ beforeEach(async () => { mintedAtFence: fence, observedAt: NOW }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const + acquisitionGeneration: `generation-${++generation}` })) dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) 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 index f17ee460653e..c8914204c133 100644 --- 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 @@ -30,6 +30,7 @@ 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' } @@ -64,14 +65,17 @@ function accepted(): AgentSessionDispatchOutcome { } function adapter(): StructuredAgentSessionAdapter { - return { - acquire, - releaseAcquisition, - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - } + 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 { 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 1d2c47beb5f4..b32d8e50119c 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 @@ -133,8 +133,7 @@ beforeEach(async () => { mintedAtFence: fence, observedAt: NOW }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const + acquisitionGeneration: `generation-${++generation}` } if (exitOnStart) { // Asked for on the lane while the start step runs, so it lands before the handover. 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 a99d7f7010ac..64b118fdee96 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 @@ -46,7 +46,6 @@ let acquire: Mock let dispatch: Mock let closeSession: Mock> let setOption: Mock -let readAcquisitionOptions: StructuredAgentSessionAdapter['readAcquisitionOptions'] let startupLimits: Partial | undefined const providerStarted = vi.fn() @@ -69,11 +68,8 @@ function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcq } } -/** An acquire that publishes at spawn: the child proves its start later, with `started`. */ -const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => ({ - ...acquisition(input), - providerChildPhase: 'starting' -}) +/** An acquire that publishes at spawn, as every one does: the child proves its start later. */ +const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => acquisition(input) async function startHost(): Promise { host = new StructuredAgentSessionHost({ @@ -87,8 +83,7 @@ async function startHost(): Promise { releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), - setOption, - ...(readAcquisitionOptions ? { readAcquisitionOptions } : {}) + setOption }, modelCatalog: { read: vi.fn(), @@ -132,7 +127,6 @@ beforeEach(async () => { })) closeSession = vi.fn(async () => true) setOption = vi.fn(async () => undefined) - readAcquisitionOptions = undefined store = await openTestAgentSessionRecordStore(root) await startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) @@ -371,7 +365,7 @@ describe('a start that ends before it proves itself', () => { await restartWith(async (input) => { await input.onSpawned?.(acquisition(input).process) chatter.push(setInterval(() => input.onOutput?.(), 10)) - return { ...acquisition(input), providerChildPhase: 'starting' } + return acquisition(input) }) try { const id = await heldBehindStart('hello') @@ -404,22 +398,7 @@ describe('a start that ends before it proves itself', () => { await eventually(() => expect(startupAttemptOpen()).toBe(false)) }) - it('ends a ready child’s option read that never answers, and fails what it held', async () => { - startupLimits = { silenceMs: 50 } - readAcquisitionOptions = () => new Promise(() => {}) - await restartWith(async (input) => { - await input.onSpawned?.(acquisition(input).process) - return acquisition(input) - }) - const id = await accept('hello') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - expect(await submission(id)).toMatchObject(HOST_STOPPED) - expect(dispatch).not.toHaveBeenCalled() - await eventually(() => expect(startupAttemptOpen()).toBe(false)) - }) - - it('ends a ready child inside its start when the chat closes before its options are read', async () => { + it('ends a start whose acquire is still running when the chat closes', async () => { let land = (): void => {} const landed = new Promise((resolve) => { land = resolve 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 deleted file mode 100644 index 64c681c9f9ed..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -// 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. - -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 type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionProviderHandle } from '../../../shared/agent-session-provider-handle' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../runtime/structured-agent-runtime-registrations' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { - StructuredAgentSessionAcquireInput, - 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 { createStructuredAgentSessionLogger } from './structured-agent-session-logger' - -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 () => { - if (root) { - await rm(root, { recursive: true, force: true }) - root = null - } -}) - -function accountHome(definition: StructuredAgentDefinition): AgentSessionAccountHome { - return definition.accountLocatorKind === 'opencode' - ? { kind: 'opencode', locator: { kind: 'unmanaged' } } - : { variable: definition.accountHomeVariable, path: `/home/dev/.${definition.agent}` } -} - -function handle(definition: StructuredAgentDefinition): AgentSessionProviderHandle { - return { transport: definition.handleTransport, agent: definition.agent, nativeId: 'native-1' } -} - -/** A host driving `definition` over a scripted adapter that publishes its child at spawn. */ -async function hostFor(definition: StructuredAgentDefinition): Promise<{ - host: StructuredAgentSessionHost - store: AgentSessionRecordStore - acquire: Mock - dispatch: Mock -}> { - root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const acquire: Mock = vi.fn( - async (input: StructuredAgentSessionAcquireInput) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: input.spawnToken - }, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - providerChildPhase: 'starting' as const, - link: { - linkId: `link-${input.fence}`, - handle: handle(definition), - origin: 'created' as const, - mintedAtFence: input.fence, - observedAt: NOW - } - }) - ) - const dispatch: Mock = vi.fn(async () => ({ - state: 'admitted' as const - })) - const adapter: StructuredAgentSessionAdapter = { - 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) - } - const declared: StructuredAgentDefinition = { - ...definition, - // The double implements none of the optional capabilities' methods. - capabilities: { ...definition.capabilities, rewind: false, compact: false, threadGoal: false } - } - const host = new StructuredAgentSessionHost({ - agents: new StructuredAgentRegistry([{ definition: declared, adapter }]), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW - }) - return { host, store, acquire, dispatch } -} - -describe.each( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( - ({ definition }) => [definition.agent, definition] as const - ) -)('%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 } = await hostFor(definition) - try { - const attached = await host.attach( - CALLER, - hostTestAttachParams(null, { - provider: agent, - agent, - accountHome: accountHome(definition), - // A new chat: the provider mints its handle at acquire. - providerHandle: undefined - }) - ) - expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) - const record = store.getRecord(SESSION)! - 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) - }) - - const body = hostTestMessage('hello') - const sent = await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: record.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe( - false - ) - ) - expect(dispatch).not.toHaveBeenCalled() - - await host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: record.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - }) - - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - } finally { - await host.flushAllStreamedEvents() - } - }) -}) - -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 index ddf04b4b0e73..224c92c4c1e9 100644 --- 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 @@ -88,22 +88,11 @@ describe('the startup clock', () => { 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' }) + attempts.published(SESSION, attempt.attemptId, CHILD) chatter(progress.output, SILENCE_MS * 3) attempts.ready(SESSION, CHILD) @@ -130,7 +119,7 @@ describe('the startup clock', () => { const attempt = mint() const progress = attempts.track(SESSION, attempt) progress.spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published(SESSION, attempt.attemptId, CHILD) chatter(progress.output, CEILING_MS - 10_000) expect(expired).toEqual([]) @@ -145,7 +134,7 @@ describe('the startup clock', () => { attempts.track(SESSION, byOutput).output() const byPublish = mint() attempts.track('session-beta', byPublish) - attempts.published('session-beta', byPublish.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published('session-beta', byPublish.attemptId, CHILD) vi.advanceTimersByTime(SILENCE_MS) @@ -159,7 +148,7 @@ 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.published(SESSION, attempt.attemptId, CHILD) attempts.ready(SESSION, { ...CHILD, generation: 'generation-0' }) expect(attempts.isOpen(SESSION)).toBe(true) @@ -173,7 +162,7 @@ describe('an attempt ends', () => { 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.published(SESSION, attempt.attemptId, CHILD) attempts.childEnded(SESSION, { ...CHILD, generation: 'generation-0' }) expect(attempts.isOpen(SESSION)).toBe(true) @@ -187,7 +176,7 @@ describe('an attempt ends', () => { 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' }) + attempts.published(SESSION, attempt.attemptId, CHILD) vi.advanceTimersByTime(SILENCE_MS) @@ -207,7 +196,7 @@ describe('an attempt ends', () => { const late = mint() attempts.track(SESSION, late).spawned() vi.advanceTimersByTime(SILENCE_MS) - attempts.published(SESSION, late.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published(SESSION, late.attemptId, CHILD) expect(expired.map((startup) => startup.child)).toEqual([null, null, CHILD]) expect(attempts.isOpen(SESSION)).toBe(false) @@ -216,10 +205,11 @@ describe('an attempt ends', () => { expect(settled.map((startup) => startup.outcome)).toEqual(['silent', 'silent']) }) - it('with an acquire that answered ready, or an attach that failed', () => { + it('with a child that proved its start, or an attach that failed', () => { const ready = mint() attempts.track(SESSION, ready).spawned() - attempts.published(SESSION, ready.attemptId, { ...CHILD, phase: 'ready' }) + attempts.published(SESSION, ready.attemptId, CHILD) + attempts.ready(SESSION, CHILD) const failed = mint() attempts.track('session-beta', failed).spawned() attempts.abandon('session-beta', failed.attemptId) 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 efecd7f77376..800cdb6dde5d 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 @@ -155,21 +155,17 @@ export class StructuredAgentSessionStartupAttempts { } } - /** The acquire returned: a `starting` child stays on the clock until it proves its start, and + /** The acquire returned: its `starting` child stays on the clock until it proves its start, and * one published after the limit passed is expired now. */ published( sessionId: string, attemptId: string, - child: StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' } + child: StructuredAgentSessionProviderChildIdentity ): void { const tracked = this.open.get(sessionId) if (tracked?.attempt.attemptId !== attemptId) { return } - if (child.phase === 'ready') { - this.settle(sessionId, tracked, 'ready') - return - } tracked.child = { generation: child.generation, fence: child.fence } if (tracked.expired) { this.expire(sessionId, tracked, null) @@ -181,6 +177,18 @@ export class StructuredAgentSessionStartupAttempts { } } + /** Whether `child`'s start is still on its clock: false once its limit passed, so a `started` + * that lost the race to the limit is never accepted. */ + onClock(sessionId: string, child: StructuredAgentSessionProviderChildIdentity): boolean { + const tracked = this.open.get(sessionId) + return ( + !this.disposed && + !!tracked?.child && + !tracked.expired && + sameProviderChild(tracked.child, child) + ) + } + /** The child proved its start: its attempt is over. A stale child's proof ends nothing. */ ready(sessionId: string, child: StructuredAgentSessionProviderChildIdentity): void { const tracked = this.open.get(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-intent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-intent.ts new file mode 100644 index 000000000000..4bf80a6df836 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-intent.ts @@ -0,0 +1,137 @@ +// Picks made while a child started are the conversation's intent, recorded at once and never a +// provider write the pick waits on. The child launched with the options saved when its start +// began; whatever the record holds now that differs is applied before the start is accepted, model +// before effort, since an effort is only valid for the model it is set on. A pick the child refuses +// is shown as what it runs; one cut short leaves the start unproven, so the child never runs it. + +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' + +const APPLY_ORDER = ['model', 'effort'] + +function applyRank(key: string): number { + const rank = APPLY_ORDER.indexOf(key) + return rank === -1 ? APPLY_ORDER.length : rank +} + +/** Each saved value the child did not launch with, in the order a provider can take them. */ +export function structuredAgentSessionStartupIntentWrites( + launched: Readonly>, + saved: Readonly> | undefined +): [key: string, value: string][] { + return Object.entries(saved ?? {}) + .filter(([key, value]) => launched[key] !== value) + .sort(([a], [b]) => applyRank(a) - applyRank(b)) +} + +/** What applying the intent came to: `aborted` when a close, Stop, quit or the startup limit cut + * it short, which leaves the start unproven; otherwise the keys the child refused. */ +export type StructuredAgentSessionStartupIntentOutcome = { + aborted: boolean + failed: string[] +} + +/** Applies the intent, each write under the start's own abort (the startup limit included). A write + * that fails is reported and its key returned, never blocking the start. */ +export async function applyStructuredAgentSessionStartupIntent( + context: { + deps: Pick + acquireAborts: Pick + optionRevisions: Pick + }, + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity, + launched: Readonly> +): Promise { + const writes = structuredAgentSessionStartupIntentWrites( + launched, + context.deps.store.getRecord(sessionId)?.options + ) + const failed: string[] = [] + if (writes.length === 0) { + return { aborted: false, failed } + } + const wait = context.acquireAborts.begin(sessionId) + try { + for (const [key, value] of writes) { + if (wait.signal.aborted) { + break + } + try { + await context.deps.adapter.setOption({ + sessionId, + key, + value, + fence: child.fence, + signal: wait.signal + }) + } catch (error) { + failed.push(key) + context.deps.logger.warn('applying a pick made while the agent started failed', { + scope: 'startup-intent', + sessionId, + key, + error + }) + } finally { + // As a ready child's pick: whatever the write's outcome, a report read before it is stale. + context.optionRevisions.advance(sessionId) + } + } + return { aborted: wait.signal.aborted, failed } + } finally { + wait.end() + } +} + +/** A pick the child refused is shown as what the child runs, never left showing a value it does + * not: what it reported, else what it launched with. Bookkeeping: a failed write is reported. */ +export async function revertRefusedStartupIntent( + context: { + deps: Pick + now: () => number + }, + input: { + sessionId: string + fence: number + refused: readonly string[] + launched: Readonly> + reported: AgentSessionOptionsResult['current'] + } +): Promise { + const { store, logger } = context.deps + const record = store.getRecord(input.sessionId) + if (input.refused.length === 0 || !record) { + return + } + const running = nativeSessionOptionsFromReport({ reported: input.reported, restoreSkipped: [] }) + const options: Record = { ...record.options } + for (const key of input.refused) { + const value = running[key] ?? input.launched[key] + if (value === undefined) { + delete options[key] + } else { + options[key] = value + } + } + try { + await store.replaceSessionOptions({ + sessionId: input.sessionId, + fence: input.fence, + options, + now: context.now() + }) + } catch (error) { + logger.warn('showing what the agent runs after it refused a pick failed', { + scope: 'startup-intent', + sessionId: input.sessionId, + error + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts new file mode 100644 index 000000000000..861a43be916f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts @@ -0,0 +1,236 @@ +// Picks made while an agent starts are applied before its start is accepted, under its startup +// clock. A pick the agent refuses is shown as what it runs; one cut short by the limit or a Stop +// leaves the start unproven, so no child ever runs a model the chat does not show. + +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 { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +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 { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_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' + +const CALLER = { callerKey: 'desktop' } +const SILENCE_MS = 10_000 + +let directory: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquires = 0 +let phases: (string | undefined)[] +const setOption = vi.fn() +const dispatch = vi.fn() +const closeSession = vi.fn(async () => true) + +beforeEach(async () => { + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] + }) + resetHostTestOperationIds() + acquires = 0 + phases = [] + setOption.mockReset().mockResolvedValue(undefined) + dispatch.mockReset().mockResolvedValue({ state: 'unknown', reason: 'unused' }) + closeSession.mockClear() + directory = await mkdtemp(join(tmpdir(), 'orca-startup-picks-')) + store = await openTestAgentSessionRecordStore(directory) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires += 1 + return { + process: { + hostId: 'local', + pid: 4000 + acquires, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires}`, + link: { + linkId: `link-${acquires}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires === 1 ? 'created' : 'resumed', + handle: codexProviderHandle(THREAD) + } + } + }, + dispatch, + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption, + releaseAcquisition: async () => true, + closeSession + }, + journalDatabase: openTestJournalHostDatabase(directory), + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + probeOwner: async () => ({ outcome: 'exit-observed' }), + idleSweep: { intervalMs: 3_600_000 }, + startupLimits: { silenceMs: SILENCE_MS, ceilingMs: 10 * SILENCE_MS } + }) + host.subscribeStatus({ + id: 'phases', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + phases.push(event.session.hostExecutionPhase) + } + } + }) + const attach = hostTestAttachParams(null) + expect( + await host.attach(CALLER, { ...attach, options: { model: 'launched-model' } }) + ).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + vi.useRealTimers() + await host.flushAllStreamedEvents() + await rm(directory, { recursive: true, force: true }) +}) + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function pick(model: string): Promise { + const fields = { key: 'model', value: model } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true }) +} + +async function sendHeld(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +/** The child reports its start, as an adapter does once its protocol session answers. */ +function started(reportedModel = 'launched-model'): Promise { + return host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquires}`, + reportedOptions: { model: reportedModel }, + restoreSkippedOptions: [], + optionRevision: 0 + }) +} + +/** A write that never answers on its own: only its abort ends it. */ +function writeHeldUntilAborted(): void { + setOption.mockImplementation( + ({ signal }) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) +} + +async function dispatchState(clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + )?.dispatchState +} + +it('shows what the agent runs when it refuses a pick made while it started', async () => { + await pick('picked-model') + expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') + setOption.mockRejectedValueOnce(new Error('model not available')) + + await started() + + expect(setOption).toHaveBeenCalledWith(expect.objectContaining({ value: 'picked-model' })) + expect(store.getRecord(SESSION)?.options?.model).toBe('launched-model') + expect(phases.at(-1)).toBe('ready') +}) + +it('never accepts a start whose limit passes while a pick is applying; the pick stays for the next start', async () => { + const held = await sendHeld('held while starting') + await pick('picked-model') + writeHeldUntilAborted() + + const proving = started() + await vi.waitFor(() => expect(setOption).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(SILENCE_MS) + await proving + + await vi.waitFor(async () => expect(await dispatchState(held)).toBe('rejected')) + expect(closeSession).toHaveBeenCalled() + expect(phases).not.toContain('ready') + expect(dispatch).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') +}) + +it('never accepts a start whose limit passed before its proof was settled', async () => { + const held = await sendHeld('held while starting') + await pick('picked-model') + const { serialize } = host.collaboratorsForTests() + const lane = Promise.withResolvers() + const blocked = serialize(SESSION, () => lane.promise) + + const proving = started() + await vi.advanceTimersByTimeAsync(SILENCE_MS) + lane.resolve() + await blocked + await proving + + await vi.waitFor(async () => expect(await dispatchState(held)).toBe('rejected')) + expect(setOption).not.toHaveBeenCalled() + expect(phases).not.toContain('ready') + expect(dispatch).not.toHaveBeenCalled() +}) + +it('ends a start a Stop cuts short while it applies a pick, never showing it ready', async () => { + await pick('picked-model') + writeHeldUntilAborted() + + const proving = started() + await vi.waitFor(() => expect(setOption).toHaveBeenCalledOnce()) + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + ).toMatchObject({ ok: true }) + await proving + + await vi.waitFor(() => expect(closeSession).toHaveBeenCalled()) + expect(phases).not.toContain('ready') + expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts index 10ea0229843c..2766c99d6367 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -1,6 +1,6 @@ // Nothing steers into a turn a person's Stop is ending: a card's Send-now or a send made then waits -// for the turn to end, and runs after it as its own turn. The host owns the rule, so a client of -// any version gets it. +// behind the Stop's end of the child, and runs after it as its own turn on the child that resumes +// the chat. The host owns the rule, so a client of any version gets it. import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' @@ -69,9 +69,27 @@ async function laneDrained(): Promise { } } -/** Turn `turn-1` running, a person's Stop ending it, and what was dispatched by then. */ +/** The child the Stop ends is handed nothing more; what follows runs on the child that resumes. */ +function expectRanOnResumedChild(dispatched: number): void { + expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1) + expect(rig.closeSession).toHaveBeenCalledOnce() + expect(rig.closeSession.mock.invocationCallOrder[0]).toBeLessThan( + rig.dispatch.mock.invocationCallOrder.at(-1) ?? 0 + ) + expect(rig.dispatch.mock.calls.at(-1)?.[0].fence).toBeGreaterThan(1) +} + +/** Holds the child's end the Stop's next step asks for, until `endChild`. */ +function holdChildEnd(): () => void { + const kill = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => kill.promise) + return () => kill.resolve(true) +} + +/** Turn `turn-1` running, a person's Stop ending it with its end of the child held, and what was + * dispatched by then. */ async function stoppingTurn(options: { card?: true; refused?: true } = {}) { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true }) const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') await turn('turn-1', sent, 'running') @@ -85,9 +103,11 @@ async function stoppingTurn(options: { card?: true; refused?: true } = {}) { if (options.refused) { rig.cancelTurn.mockResolvedValueOnce({ cancelled: false }) } + const endChild = holdChildEnd() expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(status()).toMatchObject({ stopping: true })) - return { sent, status, cardId, dispatched: rig.dispatch.mock.calls.length } + await eventually(() => expect(rig.closeSession).toHaveBeenCalledOnce()) + expect(status()).toMatchObject({ stopping: true }) + return { sent, status, cardId, endChild, dispatched: rig.dispatch.mock.calls.length } } describe("a message sent while a person's Stop ends the turn", () => { @@ -95,32 +115,33 @@ describe("a message sent while a person's Stop ends the turn", () => { ['a queued card sent now', true, false], ['a send', false, false], ['a send, after a Stop the agent declined', false, true] - ])('waits for the turn to end, then runs as its own turn: %s', async (_, fromCard, refused) => { - const { sent, status, cardId, dispatched } = await stoppingTurn({ + ])('waits for the child to end, then runs as its own turn: %s', async (_, fromCard, refused) => { + const { sent, status, cardId, endChild, dispatched } = await stoppingTurn({ ...(fromCard ? { card: true as const } : {}), ...(refused ? { refused: true as const } : {}) }) - const result = cardId ? await rig.sendNow(cardId) : await rig.send('one more thing').result - expect(result).toMatchObject({ ok: true }) - // Held on the host: the delivery loop has run its steps and handed nothing over. - await laneDrained() - const held = await rig.submission(result.ok ? result.value.clientMessageId : '') - expect(held).toMatchObject({ dispatchState: 'pending' }) - expect(held?.handedOverAt).toBeUndefined() - expect(rig.dispatch.mock.calls.length).toBe(dispatched) + // Held on the session's lane behind the Stop's end of the child. + const sending = cardId ? rig.sendNow(cardId) : rig.send('one more thing').result expect(status()).toMatchObject({ stopping: true }) - await turn('turn-1', sent, 'interrupted') + expect(rig.dispatch.mock.calls.length).toBe(dispatched) + endChild() + const result = await sending + expect(result).toMatchObject({ ok: true }) await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + expectRanOnResumedChild(dispatched) + expect(rig.dispatch.mock.calls.at(-1)?.[0].clientMessageId).toBe( + result.ok ? result.value.clientMessageId : '' + ) }) - // A Stop pressed before the turn showed holds a later send only while it settles. Settling - // having stopped nothing, the send still waits for the first send's turn to open, then joins it; - // that turn is not the Stop's. - it('hands over a send made after a Stop that stopped nothing once the turn opens, with no Stopping flip', async () => { - rig = await createQueuedMessageTestRig() + // A Stop pressed before the turn showed, which the agent declined, still ends the child, whose + // end takes the first send back. A send made while it settles runs on the child that resumes the + // chat, and Stopping never comes back for its turn. + it('hands a send made while a declined Stop settles to the resumed child, with no Stopping flip', async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) const first = await rig.workingSend() const seen: (true | undefined)[] = [] rig.host.subscribeStatus({ @@ -137,18 +158,17 @@ describe("a message sent while a person's Stop ends the turn", () => { await eventually(() => expect(seen.at(-1)).toBe(true)) const dispatched = rig.dispatch.mock.calls.length - // Accepted on the session's lane behind the Stop, so it lands once the Stop settles. + // Accepted on the session's lane behind the Stop and its end of the child. const later = rig.send('sent before the turn opened') answer.resolve({ cancelled: false }) expect(await stopped).toMatchObject({ ok: true }) expect(await later.result).toMatchObject({ ok: true }) - const { loop } = rig.host.collaboratorsForTests().conversationDelivery - await eventually(() => expect(loop.isRunning(HOST_TEST_SESSION)).toBe(false)) - expect((await rig.submission(later.id))?.handedOverAt).toBeUndefined() - - await rig.settleAccepted(first, 'first') - await turn('turn-1', first, 'running') await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + expectRanOnResumedChild(dispatched) + expect(await rig.submission(first)).toMatchObject({ dispatchState: 'rejected' }) + + await rig.settleAccepted(later.id, 'later') + await turn('turn-2', later.id, 'running') await laneDrained() expect(seen.at(-1)).toBeUndefined() // Once Stopping ended it never came back. @@ -175,7 +195,7 @@ describe("a message sent while a person's Stop ends the turn", () => { // The delivery step that judged the send and its handover are two turns of the session's lane. it('holds a send that a Stop overtook between its delivery step and the handover', async () => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true }) const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') await turn('turn-1', sent, 'running') @@ -188,24 +208,31 @@ describe("a message sent while a person's Stop ends the turn", () => { const dispatched = rig.dispatch.mock.calls.length // The Stop withdraws the first send; a second one arrives before the handover. + const endChild = holdChildEnd() const stopped = rig.stop() const second = rig.send('and this one') step.release() expect(await stopped).toMatchObject({ ok: true }) - expect(await second.result).toMatchObject({ ok: true }) - await eventually(() => expect(status()).toMatchObject({ stopping: true })) - await laneDrained() + await eventually(() => expect(rig.closeSession).toHaveBeenCalledOnce()) + expect(status()).toMatchObject({ stopping: true }) - expect(rig.dispatch.mock.calls.length).toBe(dispatched) - expect((await rig.submission(second.id))?.handedOverAt).toBeUndefined() await turn('turn-1', sent, 'interrupted') + expect(rig.dispatch.mock.calls.length).toBe(dispatched) + endChild() + expect(await second.result).toMatchObject({ ok: true }) await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + expectRanOnResumedChild(dispatched) + expect(rig.dispatch.mock.calls.at(-1)?.[0].clientMessageId).toBe(second.id) + expect(await rig.submission(first.id)).toMatchObject({ dispatchState: 'rejected' }) }) // The hold reads the status feed's own projection for the commit, never a journal read of its own. it('reads Stopping once per commit while a send waits on it', async () => { - const { cardId } = await stoppingTurn({ card: true }) - expect(await rig.sendNow(cardId ?? '')).toMatchObject({ ok: true }) + rig = await createQueuedMessageTestRig() + await turn('turn-1', await rig.workingSend(), 'running') + await journal().appendStopEvent({ reason: 'user-stop' }, 1) + const settle = journal().stopMarks.beginSettle() + expect(await rig.send('sent while the Stop settles').result).toMatchObject({ ok: true }) await laneDrained() const reads = vi.spyOn(journal(), 'snapshot') @@ -222,5 +249,6 @@ describe("a message sent while a person's Stop ends the turn", () => { } expect(reads.mock.calls.length).toBeLessThanOrEqual(3) + journal().stopMarks.settled(settle) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index c09d2eba456c..6c24a4ebcf7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -20,10 +20,7 @@ import { QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' -import { - openRigTurnFor, - withdrawnByStop -} from './structured-agent-session-queued-rig-turn.test-fixture' +import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' let rig: QueuedMessageTestRig @@ -62,6 +59,11 @@ function fence(): number { return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 } +/** A Stop's second step, which ends the child, runs next on the session's lane. */ +function laneDrained(): Promise { + return rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) +} + async function laterTurn() { const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) return items @@ -69,15 +71,24 @@ async function laterTurn() { .find((turn) => turn?.turnId === 'turn-later') } -/** Every end row written for the later turn, in order: a relabel would show as two. */ +/** Every end row written for the later turn, in order, a child end's batch included: a relabel + * would show as two. */ function laterTurnEndRows() { const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) if (!since.ok) { throw new Error(`expected rows, got reset ${since.reset}`) } return since.rows.flatMap((row) => { - const turn = row.kind === 'item' ? readAgentJournalTurn(row.body) : undefined - return turn?.turnId === 'turn-later' && turn.state !== 'running' ? [turn] : [] + const bodies = + row.kind === 'item' + ? [row.body] + : row.kind === 'lifecycle-batch' + ? row.mutations.flatMap((mutation) => (mutation.kind === 'item' ? [mutation.body] : [])) + : [] + return bodies.flatMap((body) => { + const turn = readAgentJournalTurn(body) + return turn?.turnId === 'turn-later' && turn.state !== 'running' ? [turn] : [] + }) }) } @@ -215,7 +226,7 @@ describe('a Stop pressed before its send opened a turn binds only the turn it st async function stopBeforeTheTurnShowed( answer: Awaited> = { cancelled: true } ): Promise<{ stopped: string; held: string }> { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true }) const stopped = await rig.workingSend() const held = await queuedDraft('queued behind the turn') rig.cancelTurn.mockResolvedValueOnce(answer) @@ -251,21 +262,26 @@ describe('a Stop pressed before its send opened a turn binds only the turn it st expect(journal().activeTurnId()).toBeNull() }) - it('binds no turn that opens after a Stop that stopped nothing', async () => { + // The provider's answer does not decide the Stop: the child's end does, taking back the send. + it('binds no turn the next send opens after a Stop the provider said stopped nothing', async () => { const { stopped } = await stopBeforeTheTurnShowed({ cancelled: false }) - await rig.settleAccepted(stopped, 'stopped') + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(await rig.submission(stopped)).toMatchObject({ dispatchState: 'rejected' }) + const next = await rig.workingSend() - await turnOpenedBy(stopped) - await turnOpenedBy(stopped, 'interrupted') + await turnOpenedBy(next) + await turnOpenedBy(next, 'interrupted') await expectNews() }) it("writes the host's event when it evicts the turn of the card Resume sent", async () => { - const { stopped, held } = await stopBeforeTheTurnShowed() - await rig.settleAccepted(stopped, 'stopped') + const { held } = await stopBeforeTheTurnShowed() + // The child's end took the unopened send back; Resume sends the card to the next child. + await laneDrained() expect(await rig.resume()).toMatchObject({ ok: true }) - await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + await eventually(async () => expect((await rig.handoff(held))?.handedOverAt).toBeDefined()) await turnOpenedBy(await rig.handoffId(held)) expect(await evictedAt()).toEqual(['user-stop', 'evict']) @@ -288,12 +304,13 @@ describe('a Stop pressed before its send opened a turn binds only the turn it st describe('a press that writes no Stop event of its own', () => { // A turnless Stop long since settled, then a later turn the person's own send opened. async function laterTurnAfterAnEarlierStop(): Promise { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true }) const stopped = await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - // Its turn never opened, so the Stop took it back, as the provider's Stop settles one. - await withdrawnByStop(rig, stopped) + // Its turn never opened, so the child's end took it back; the next send starts a new child. + await laneDrained() + expect(await rig.submission(stopped)).toMatchObject({ dispatchState: 'rejected' }) const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') await turnOpenedBy(sent) @@ -344,13 +361,18 @@ describe("a Stop's settle that ends the turn its interrupt took", () => { rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-later' }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() - expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(laterTurnEndRows()).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) expect(journal().activeTurnId()).toBeNull() }) }) describe('a host stop with no turn running after a Stop that named none', () => { + // The Stop settles until its next step has ended the child. it('defers to that Stop while it settles, and not after', async () => { rig = await createQueuedMessageTestRig() await rig.workingSend() @@ -361,6 +383,8 @@ describe('a host stop with no turn running after a Stop that named none', () => }) expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.personStopDecides(null)).toBe(true) + await laneDrained() expect(whileSettling).toBe(true) expect(journal().stopMarks.personStopDecides(null)).toBe(false) @@ -392,7 +416,10 @@ describe('a host stop with no turn running after a Stop that named none', () => const card = await queuedDraft('queued behind the send') rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + // The Stop answers before its next step tries to end the child. + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledTimes(1) expect(await evictedAt()).toEqual(['user-stop']) expect(await stillStopped()).toBe(true) @@ -400,12 +427,15 @@ describe('a host stop with no turn running after a Stop that named none', () => }) it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true, windsDown: true }) const stopped = await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) - // The stopped send's turn opens after that turnless Stop, and ends interrupted, unanswered. + // The stopped send's turn opens after that turnless Stop, and ends interrupted, unanswered, + // while the child winds down; the mail then reaches the next child. await openRigTurnFor(rig, stopped) await openRigTurnFor(rig, stopped, 'interrupted') + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledTimes(1) const mail = rig.send('mail for the lead') await mail.result await eventually(async () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 17a274d6f5fc..024deed069b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -77,6 +77,38 @@ function idleSweep() { return rig.host.collaboratorsForTests().lifetime.idleSweep } +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence +} + +/** A Stop's second step, which ends the child, runs next on the session's lane. */ +function laneDrained(): Promise { + return rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) +} + +/** A person's Stop ended the first child; the next send's turn, on a new child, then runs. */ +async function turnAfterAStop(): Promise { + rig = await createQueuedMessageTestRig({ restartable: true }) + await rig.workingSend() + await queuedDraft('queued behind the send') + expect(await rig.stop()).toMatchObject({ ok: true }) + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledTimes(1) + const working = await rig.workingSend() + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, + { + kind: 'turn', + turnId: 'turn-1', + state: 'running', + startedAt: 1, + userItemId: agentJournalSubmissionKey(working) + }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return working +} + describe('every Stop entry writes its event, with its reason, before it ends the child', () => { it.each([ // A person closing this chat: its tab, its launch, or a /clear that replaces it. @@ -192,12 +224,11 @@ describe('every Stop entry writes its event, with its reason, before it ends the // A steer delivered into the running turn opens no turn of its own, so it is never owed one. it.each([ ['', false], - [', its last send a steer into the stopped turn', true] + [', its last send a steer into the turn that ended', true] ])( 'writes nothing when the drain runs long as it evicts a chat at rest%s', async (_label, steered) => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() + const working = await turnAfterAStop() const opener = agentJournalSubmissionKey(working) const identity = { provider: 'codex' as const, @@ -205,12 +236,7 @@ describe('every Stop entry writes its event, with its reason, before it ends the turnId: 'turn-1', ordinal: 999 } - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - await journal().appendItem( - identity, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, - scope - ) + const scope = { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } if (steered) { const steer = rig.send('steer the running turn') await steer.result @@ -224,21 +250,19 @@ describe('every Stop entry writes its event, with its reason, before it ends the .items.find((item) => item.itemId === agentJournalSubmissionKey(steer.id))?.turnScope ).toMatchObject({ kind: 'turn' }) } - await queuedDraft('queued behind the turn') - expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'turn-1') await journal().appendItem( identity, { kind: 'turn', turnId: 'turn-1', - state: 'interrupted', + state: 'completed', + outcome: 'success', completedAt: Date.now(), userItemId: opener }, scope ) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) const drained = sink.drained.bind(sink) vi.spyOn(sink, 'drained') @@ -251,23 +275,18 @@ describe('every Stop entry writes its event, with its reason, before it ends the await rig.host.close(HOST_TEST_SESSION, 'evict') expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - // The close hides the row, as every close of a chat does; the Stop still pauses. - expect(await rig.queuePause()).toBeNull() - expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toContain('stopped') }, 20_000 ) // The drain is best effort: when it fails, the journal as it stands says the agent rests. it('writes nothing when the drain fails as it evicts a chat at rest', async () => { - rig = await createQueuedMessageTestRig() - const working = await runningTurn() - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'stopped') + const working = await turnAfterAStop() + await rig.settleAccepted(working, 'turn-1') await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, { kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(journal().activeTurnId()).toBeNull() const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) @@ -317,19 +336,21 @@ describe('every Stop entry writes its event, with its reason, before it ends the }) describe("a person's Stop pause and the Stop events after it", () => { - it('holds through an idle eviction of the chat at rest, which writes nothing', async () => { + // The Stop's own end of the child is the one close; the sweep after it finds nothing to end. + it('holds through the idle sweep after the Stop ended the child, which writes nothing', async () => { rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) - const working = await rig.workingSend() + await rig.workingSend() const held = await queuedDraft('queued behind the turn') + const atClose = stopEventsAtClose() expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'stopped') + await laneDrained() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - const atClose = stopEventsAtClose() await idleSweep().tick() expect(rig.closeSession).toHaveBeenCalledTimes(1) expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + expect(stopEvents().map((event) => event.reason)).toEqual(['user-stop']) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(await rig.handoff(held)).toBeUndefined() }) @@ -337,11 +358,12 @@ describe("a person's Stop pause and the Stop events after it", () => { // The sweep rests an agent with no turn running even while a send whose reply was lost waits, // so that send cannot pin it forever; that rest ends no work, so it writes no event. it('holds through an idle eviction that retires a send whose reply was lost', async () => { - rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) - const working = await rig.workingSend() + rig = await createQueuedMessageTestRig({ restartable: true, idleSweep: MANUAL_IDLE_SWEEP }) + await rig.workingSend() await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'stopped') + await laneDrained() + // The send starts a new child, as any send after a Stop does. rig.dispatch.mockRejectedValueOnce(new Error('reply lost')) const lost = rig.send('sent as the reply was lost') await lost.result @@ -353,7 +375,7 @@ describe("a person's Stop pause and the Stop events after it", () => { await idleSweep().tick() - expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(rig.closeSession).toHaveBeenCalledTimes(2) expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) @@ -376,18 +398,23 @@ describe("a person's Stop pause and the Stop events after it", () => { }) it('ends when a host eviction ends a running turn: that Stop event is later', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() + rig = await createQueuedMessageTestRig({ restartable: true }) + await rig.workingSend() await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'stopped') + await laneDrained() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - // Orchestration mail's turn runs, but its send is not accepted yet, so it lifts nothing yet. - await rig.send('mail for the lead').result + // Orchestration mail's turn runs on a new child, but its send is not accepted yet, so it + // lifts nothing yet. + const mail = rig.send('mail for the lead') + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['stopped']) const atClose = stopEventsAtClose() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 55eb9080a5b1..2f71f6c253cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -55,12 +55,18 @@ function journal() { return open } +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence +} + +/** A Stop's second step, which ends the child, runs next on the session's lane. */ +function laneDrained(): Promise { + return rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) +} + /** A send the provider is working on, with its turn running since half a minute ago. */ -async function runningTurn( - identity: AgentJournalItemIdentity, - options: { stopEndsSession?: true } = {} -): Promise { - rig = await createQueuedMessageTestRig(options) +async function runningTurn(identity: AgentJournalItemIdentity): Promise { + rig = await createQueuedMessageTestRig() await rig.workingSend() await journal().appendItem( identity, @@ -69,11 +75,20 @@ async function runningTurn( ) } +/** Orca dies inside the Stop's end of the child: the provider's close never answers, so nothing + * that end settles is written. */ +async function stopDiesEndingTheChild(stop: () => Promise): Promise { + rig.closeSession.mockImplementationOnce(() => new Promise(() => undefined)) + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await expect.poll(() => rig.closeSession.mock.calls.length).toBe(1) +} + /** Orca dies with the turn's end unwritten; the relaunch reopens the chat from disk and proves * the old child gone (its last renewal came before the Stop), then settles what it left. */ async function restartAndSettle( proof: 'pid-absent' | 'exit-observed' | 'unproven' = 'pid-absent' ): Promise { + const owner = fence() rig.crashRestartHostProcess() await rig.host.journalSnapshot(HOST_TEST_SESSION) const now = Date.now() @@ -84,13 +99,13 @@ async function restartAndSettle( kind: proof, detail: 'the relaunch proved the old child gone', observedAt: now + 60_000, - ownerFence: 1, + ownerFence: owner, lastProvenAliveAt: now - 20_000 } await settleStaleStructuredAgentSessionState({ journal: journal(), sessionId: HOST_TEST_SESSION, - fence: 2, + fence: owner + 1, acquisitionGeneration: 'generation-2', deathEvidence }) @@ -126,7 +141,7 @@ describe('a restart between a Stop and its turn end', () => { ])('reads Interrupted after N, marked interrupted: %s', async (_label, identity) => { await runningTurn(identity) // The provider took the interrupt; its end never arrived. - expect(await rig.stop()).toMatchObject({ ok: true }) + await stopDiesEndingTheChild(() => rig.stop()) await restartAndSettle() @@ -197,7 +212,7 @@ describe('a restart between a Stop and its turn end', () => { it("reads Couldn't confirm when the relaunch cannot prove the child gone, Stop or not", async () => { // The Stop says whose end it was, never that the turn ended. await runningTurn(CODEX_TURN) - expect(await rig.stop()).toMatchObject({ ok: true }) + await stopDiesEndingTheChild(() => rig.stop()) await restartAndSettle('unproven') @@ -207,8 +222,9 @@ describe('a restart between a Stop and its turn end', () => { }) // Codex refuses a Stop naming a turn that is no longer its active one ("expected active turn id - // X but found Y"), as a turn not running, so the child stays. The Stop names X, so Y's end is - // never the person's, by its turn id alone. + // X but found Y"), as a turn not running. The journal still showed X, so the Stop goes on to end + // the child, and Orca dies inside that end. The Stop names X, so Y's end is never the person's, + // by its turn id alone. it('reads Failed for the turn running when the provider refused a Stop naming the one before it', async () => { await runningTurn(CODEX_TURN) rig.cancelTurn.mockResolvedValueOnce({ @@ -222,12 +238,12 @@ describe('a restart between a Stop and its turn end', () => { } }) const fields = { turnId: TURN } - expect( - await rig.host.cancel(QUEUED_RIG_CALLER, { + await stopDiesEndingTheChild(() => + rig.host.cancel(QUEUED_RIG_CALLER, { envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), ...fields }) - ).toMatchObject({ ok: true, value: { cancelled: false } }) + ) expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-stop', turnId: TURN }) // The journal catches up: X had finished, and Y runs on until the crash. await journal().appendItem( @@ -256,13 +272,13 @@ describe('a restart between a Stop and its turn end', () => { expect(mark).toBe('failed') }) - // Claude's Stop ends its child whatever the interrupt answered. + // A Stop ends the child whatever the interrupt answered. it('reads Interrupted after N when the provider refused a Stop that ends its child', async () => { - await runningTurn(CLAUDE_TURN, { stopEndsSession: true }) + await runningTurn(CLAUDE_TURN) rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) // The Stop's next step on the session's lane ends the child. - await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) + await laneDrained() expect(rig.closeSession).toHaveBeenCalled() await restartAndSettle() @@ -271,19 +287,20 @@ describe('a restart between a Stop and its turn end', () => { }) it('reads a turn a send made after a Stop pressed before any turn showed as no Stop of its', async () => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true }) const stopped = await rig.workingSend() // Pressed before the turn showed: the Stop names no turn. expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() - await rig.settleAccepted(stopped, 'stopped') - const next = rig.send('sent after the Stop') - await next.result - await rig.settleAccepted(next.id, 'next') + // The child's end took back the send whose turn never opened; the next send starts a new one. + await laneDrained() + expect(await rig.submission(stopped)).toMatchObject({ dispatchState: 'rejected' }) + const next = await rig.workingSend() + await rig.settleAccepted(next, 'next') await journal().appendItem( CODEX_TURN, { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // Its exit is proven after the Stop, so only whose turn it is decides. @@ -293,9 +310,10 @@ describe('a restart between a Stop and its turn end', () => { expect(settled().turn).not.toHaveProperty('outcome') }) - // A Stop pressed before any turn showed stopped the turn its interrupt took, and no other. + // A Stop pressed before any turn showed stopped the turn its interrupt took, and no other. That + // turn opens and ends as the child winds down, before the Stop ends it. it('reads a turn a host send opened after the turnless Stop ended its own turn as no Stop of its', async () => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true, windsDown: true }) const stopped = await rig.workingSend() rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: TURN }) expect(await rig.stop()).toMatchObject({ ok: true }) @@ -320,11 +338,12 @@ describe('a restart between a Stop and its turn end', () => { }, scope ) + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledTimes(1) expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) - // A host send after the Stop opens its own turn. - const drained = rig.send('drained after the Stop') - await drained.result - await rig.settleAccepted(drained.id, 'drained') + // A host send after the Stop opens its own turn, on the next child. + const drained = await rig.workingSend() + await rig.settleAccepted(drained, 'drained') await journal().appendItem( CODEX_NEXT_TURN, { @@ -332,9 +351,9 @@ describe('a restart between a Stop and its turn end', () => { turnId: NEXT_TURN, state: 'running', startedAt: Date.now(), - userItemId: agentJournalSubmissionKey(drained.id) + userItemId: agentJournalSubmissionKey(drained) }, - scope + { ...scope, fence: fence() } ) // Its exit is proven after the Stop, so only which turn the Stop stopped decides. 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 ea0dae59199e..51c0fdddb19a 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 @@ -52,10 +52,18 @@ async function turnRow(turnId: string, state: 'running' | 'interrupted') { return journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, { kind: 'turn', turnId, state, startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { + fence: rig.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) } +/** A Stop's second step, which ends the child, runs next on the session's lane. */ +function laneDrained(): Promise { + return rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) +} + function withdraw(clientMessageId: string) { return rig.host.settleLateDispatch({ sessionId: HOST_TEST_SESSION, @@ -69,12 +77,13 @@ describe("a Stop's event", () => { it('reaches the journal before the interrupt, naming the turn it stopped and who asked', async () => { rig = await createQueuedMessageTestRig() await rig.workingSend() + await turnRow('turn-1', 'running') let atInterrupt: JournalStopEvent[] = [] rig.cancelTurn.mockImplementationOnce(async () => { atInterrupt = stopEvents() return { cancelled: true } }) - const fields = { turnId: 'turn-named' } + const fields = { turnId: 'turn-1' } const stopped = await rig.host.cancel(QUEUED_RIG_CALLER, { envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), ...fields @@ -84,7 +93,7 @@ describe("a Stop's event", () => { expect(atInterrupt).toEqual([ { reason: 'user-stop', - turnId: 'turn-named', + turnId: 'turn-1', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } @@ -169,36 +178,60 @@ describe("a Stop's event", () => { ]) }) - it('a second press while the first interrupt lands writes nothing: a card queued between them sends normally', async () => { - rig = await createQueuedMessageTestRig() + // The second press and a send made meanwhile wait behind the first Stop's end of the child. + it('a second press while the first winds down writes nothing: a send made meanwhile reaches the next child', async () => { + rig = await createQueuedMessageTestRig({ restartable: true, windsDown: true }) const working = await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - const between = await queuedDraft('queued between the presses') - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(stopEvents()).toHaveLength(1) + const again = rig.stop() + const between = rig.send('sent between the presses', 'queue-if-active') await rig.settleAccepted(working, 'stopped') - await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) + expect(await again).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(await between.result).toMatchObject({ ok: true }) + expect(stopEvents()).toHaveLength(1) + await eventually(async () => + expect((await rig.submission(between.id))?.handedOverAt).toBeDefined() + ) + expect(rig.dispatch).toHaveBeenLastCalledWith( + expect.objectContaining({ clientMessageId: between.id }) + ) }) - it('a second press once the turn shows, after a first before it did, writes nothing: a card queued between sends', async () => { - rig = await createQueuedMessageTestRig() + it('a second press once the turn shows, after a first before it did, writes nothing: a send made meanwhile reaches the next child', async () => { + rig = await createQueuedMessageTestRig({ restartable: true, windsDown: true }) const working = await rig.workingSend() - await rig.stop() - const between = await queuedDraft('queued between the presses') + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + // The echo of the interrupted send opens its turn while the Stop winds down. await turnRow('turn-1', 'running') - await rig.stop() - expect(stopEvents()).toHaveLength(1) + const again = rig.stop() + const between = rig.send('sent between the presses', 'queue-if-active') await rig.settleAccepted(working, 'stopped') await turnRow('turn-1', 'interrupted') - await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) + expect(await again).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(await between.result).toMatchObject({ ok: true }) + expect(stopEvents()).toHaveLength(1) + await eventually(async () => + expect((await rig.submission(between.id))?.handedOverAt).toBeDefined() + ) }) - it('a second press after a card sent into the turn settled unknown writes again', async () => { - rig = await createQueuedMessageTestRig() - await rig.workingSend() + /** After a turnless Stop ended the first child: the next send's turn runs on a new one. */ + async function turnAfterAnEndedChat(options: { windsDown?: true } = {}): Promise { + rig = await createQueuedMessageTestRig({ restartable: true, ...options }) + const first = 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') + // Its turn never opened, so the provider takes it back as the interrupt lands. + await withdraw(first) + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledTimes(1) + const working = await rig.workingSend() + await turnRow('turn-2', 'running') + return working + } + + it('a second press after a card sent into the next turn settled unknown writes again', async () => { + await turnAfterAnEndedChat() 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()) @@ -212,19 +245,19 @@ describe("a Stop's event", () => { expect(stopEvents()).toHaveLength(2) }) - it('a second press after a card was sent into the turn writes again, and holds that card', async () => { - rig = await createQueuedMessageTestRig() - 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') + it('a second press after a card was sent into the next turn writes again, and holds that card', async () => { + const working = await turnAfterAnEndedChat({ windsDown: true }) 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()) await rig.stop() expect(stopEvents()).toHaveLength(2) + // As the child winds down, the provider takes the card back and the stopped turn ends. await withdraw(await rig.handoffId(steered)) await rig.settleAccepted(working, 'stopped') + await turnRow('turn-2', 'interrupted') + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledTimes(2) await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.drafts()).toEqual([{ messageId: steered, state: 'waiting' }]) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index 4cbc39073c17..b0c22c453fc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -49,13 +49,12 @@ async function runningTurn(): Promise { return journal } -/** A child the adapter published before proving its startup, so a Stop ends the start. */ +/** A child whose handshake never answers, so a Stop ends the start. */ async function startingChild(): Promise<{ journal: AgentSessionJournal; closeSession: Mock }> { - const acquired = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => ({ - ...(await acquired(input)), - providerChildPhase: 'starting' as const - })) + const handle = host.handleAdapterEvent.bind(host) + vi.spyOn(host, 'handleAdapterEvent').mockImplementation(async (event) => + event.type === 'started' ? undefined : handle(event) + ) const closeSession = vi.fn(async () => true) Object.assign(host.deps.adapter, { closeSession }) await attach() @@ -176,13 +175,13 @@ describe.each([ } ) - // A provider whose Stop ends its session: the child's end, in the Stop's next step, goes out - // before the write settles too; that step holds the lane for the Stop event instead. + // The Stop ends the session: the child's end, in the Stop's next step, goes out before the write + // settles too; that step holds the lane for the Stop event instead. it.each([ ['withdrawing the queued sends', 'withdrawal'], ['writing its Stop event', 'event row'] ] as const)( - "ends a session-ending provider's child before %s settles, then reports its failure", + 'ends a running child before %s settles, then reports its failure', async (step, failed) => { const journal = await runningTurn() const order: string[] = [] @@ -190,7 +189,7 @@ describe.each([ order.push('kill') return true }) - Object.assign(host.deps.adapter, { stopEndsSession: () => true, closeSession }) + Object.assign(host.deps.adapter, { closeSession }) const held = Promise.withResolvers() if (step === 'withdrawing the queued sends') { vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(() => held.promise) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts index a3fee6b2ed40..a5d283b793b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -1,6 +1,6 @@ // A Stop pressed before its send's turn showed binds the turn that send opens only by stopping it: -// a Codex turn that did not open in time ends with the child the Stop ends, and a Claude turn that -// opens before the Stop's child end is proven ends as the Stop's. Nothing reads as running after. +// the Stop's next step ends the child, and a turn that opens before that end is proven ends as the +// Stop's, whether or not the agent took the interrupt. Nothing reads as running after. import { afterEach, describe, expect, it } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' @@ -89,6 +89,7 @@ describe('a Codex Stop whose answered turn did not open in time', () => { await stopOfAnsweredSend() expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await windDown() expect(rig.closeSession).toHaveBeenCalled() expect(openedTurn()).toBeUndefined() @@ -103,6 +104,7 @@ describe('a Codex Stop whose answered turn did not open in time', () => { }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await windDown() expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) expect(nothingRuns()).toBe(true) @@ -116,8 +118,11 @@ describe('a Codex Stop that could not reach a turn still able to open, whose kil rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + // The Stop answers before its next step tries the child's end; that end's failure revises the note. + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await windDown() + expect(rig.closeSession).toHaveBeenCalled() const rows = journal() .snapshot() .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.failure?.kind] : [])) @@ -125,9 +130,9 @@ describe('a Codex Stop that could not reach a turn still able to open, whose kil }) }) -describe('a Claude Stop pressed before its send echoed', () => { +describe('a Stop the agent took, pressed before its send echoed', () => { it('reads the turn that opens before its child end is proven as interrupted by the Stop', async () => { - rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + rig = await createQueuedMessageTestRig() const sent = await rig.workingSend() rig.closeSession.mockImplementationOnce(async () => { await turnOpens(sent) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts index 8750fe909a0a..3a1bbba00754 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -8,9 +8,11 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody, type AgentJournalItemIdentity, + type AgentJournalStatusItem, + type AgentJournalSubmission, type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' -import { isMainAgentWorking } from './structured-agent-session-turns-cancel' +import { isMainAgentWorking, tookBackEverySend } from './structured-agent-session-turns-cancel' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' @@ -25,6 +27,13 @@ export type StructuredAgentSessionStopWindDown = { stoppedAt: number /** The note the Stop wrote, which a failed wind-down revises. */ stopNote: AgentJournalItemIdentity + /** A note the Stop left to its end: written under `stopNote` unless the child's end took back + * every one of these sends, whose own rows then say they never started. */ + deferredNote?: { + body: AgentJournalStatusItem + turnScope: AgentJournalTurnScope + unlessTakenBack: readonly AgentJournalSubmission[] + } /** Closes the Stop's settle once the wind-down finishes, whether or not the child's exit was * proven, marking the turn running on after an end that failed (`JournalStopSettle.failedOn`): * until then, what ends is the Stop's. */ @@ -49,15 +58,33 @@ export async function endStoppedStructuredAgentSession( await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) } await stopChild() + await writeDeferredStopNote(ctx, windDown, (taken) => !tookBackEverySend(ctx, taken)).catch( + onError + ) } catch (error) { onError(error) failedOn = structuredAgentSessionFailedStopMark(ctx.journal) + await writeDeferredStopNote(ctx, windDown, () => true).catch(onError) await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError) } finally { windDown.settled?.(failedOn) } } +async function writeDeferredStopNote( + ctx: Pick, + windDown: StructuredAgentSessionStopWindDown, + stillOwed: (taken: readonly AgentJournalSubmission[]) => boolean +): Promise { + const deferred = windDown.deferredNote + if (deferred && stillOwed(deferred.unlessTakenBack)) { + await ctx.journal.appendItem(windDown.stopNote, deferred.body, { + fence: ctx.fence, + turnScope: deferred.turnScope + }) + } +} + /** While the work it stopped runs on, the Stop's own note, if it wrote one, says what a lost * interrupt's says. Work that ended took the Stop, whatever became of the child. */ async function reviseStopNoteUnconfirmed( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index 92cd6f574331..20ad3f338c82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -1,7 +1,7 @@ // The host's "Stopping…": published on the session's status while a person's Stop is still // settling, and then while the turn it stopped, or failed to stop, still runs, until that turn ends. -// A Stop that settles having stopped nothing ends it. Driven through the real host and its status -// feed, with turn rows named as Codex writes them. +// Every chat Stop ends the child in its next step on the session's lane. Driven through the real +// host and its status feed, with turn rows named as Codex writes them. import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -22,6 +22,7 @@ import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import type { QueuedRigProviderOptions } from './structured-agent-session-queued-message-rig-provider.test-fixture' import { createQueuedMessageTestRig, eventually, @@ -97,8 +98,13 @@ function stopAnswers(): (string | undefined)[] { .map((item) => (item.body.kind === 'status' ? (item.body.failure?.kind ?? 'took') : undefined)) } +/** The Stop's next step on the session's lane, where it ends the child, has run. */ +async function laneDrained(): Promise { + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) +} + /** A send whose turn `turn-1` is running, and the session's status. */ -async function runningTurn(options: { stopEndsSession?: true } = {}) { +async function runningTurn(options: QueuedRigProviderOptions = {}) { rig = await createQueuedMessageTestRig(options) const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') @@ -145,21 +151,19 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => ['left unconfirmed', 'names no turn', unconfirmed], ['left unconfirmed', 'names the turn', unconfirmed] ])( - 'keeps Stopping until the turn ends after the agent %s a Stop that %s', + 'keeps Stopping until the turn ends after the agent %s a Stop that %s, whose child end failed', async (_, naming, cancel) => { const { sent, status } = await runningTurn() rig.cancelTurn.mockImplementationOnce(cancel) - if (cancel === unconfirmed) { - // A failed interrupt ends the agent's process; this one cannot be ended either. - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - } + // The Stop ends the agent's process next; this one cannot be ended. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) const stopped = naming === 'names the turn' ? namedStop('turn-1') : rig.stop() expect(await stopped).toMatchObject({ ok: true }) + await laneDrained() - // The answer says it stopped nothing, yet the Stop is still the person's: a repeat escalates. - await eventually(() => expect(stopAnswers()).toHaveLength(1)) - expect(stopAnswers()[0]).not.toBe('took') + // The note says the Stop is unconfirmed, yet it is still the person's: a repeat escalates. + expect(stopAnswers()).toEqual(['cancelUnconfirmed']) expect(status()).toMatchObject({ status: 'working', stopping: true }) await turn('turn-1', sent, 'interrupted') @@ -191,21 +195,28 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => it('keeps Stopping through a repeat press of a refused Stop, which writes no second event', async () => { const { status } = await runningTurn() rig.cancelTurn.mockImplementationOnce(async () => ({ cancelled: false })) + // Neither the Stop's end of the child nor the repeat's joined close proves the exit. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out again')) expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(journal().stopMarks.latest()).not.toBeNull()) + await laneDrained() + expect(journal().stopMarks.latest()).not.toBeNull() const refusedStop = journal().stopMarks.latest() expect(await rig.stop()).toMatchObject({ ok: true }) + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledTimes(2) expect(journal().stopMarks.latest()).toEqual(refusedStop) - await eventually(() => expect(status()).toMatchObject({ stopping: true })) + expect(status()).toMatchObject({ status: 'working', stopping: true }) }) // The newest turn record is the stopped one until the next send's turn opens; it no longer runs. + // That send runs on the child that resumes the chat once the Stop's wind-down ended the old one. it.each(['names its turn', 'names no turn'])( 'reads a send made after a Stop that %s as Working before its turn opens', async (naming) => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true, windsDown: true }) const stopped = await rig.workingSend() const status = watchStatus() if (naming === 'names its turn') { @@ -215,14 +226,18 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => expect(await namedStop('turn-1')).toMatchObject({ ok: true }) } else { // The provider's answer names the turn the send opened, which the Stop waited for. - rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-1' }) + rig.cancelTurn.mockImplementationOnce(async () => { + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'running') + return { cancelled: true, turnId: 'turn-1' } + }) expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(stopped, 'stopped') - await turn('turn-1', stopped, 'running') } await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) await turn('turn-1', stopped, 'interrupted') await eventually(() => expect(status()?.status).toBe('idle')) + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledOnce() await rig.workingSend() @@ -232,10 +247,12 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => ) it('never marks a turn that opened after the stopped one ended', async () => { - const { sent, status } = await runningTurn() + const { sent, status } = await runningTurn({ restartable: true, windsDown: true }) expect(await rig.stop()).toMatchObject({ ok: true }) await turn('turn-1', sent, 'interrupted') await eventually(() => expect(status()?.status).toBe('idle')) + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledOnce() const next = await rig.workingSend() await rig.settleAccepted(next, 'next') @@ -255,26 +272,25 @@ describe('a Stop that failed before the turn it meant to stop opened', () => { .find((body) => body.kind === 'turn' && body.turnId === turnId) } - async function failedBeforeTheTurn(options: { stopEndsSession?: true; restartable?: true } = {}) { - rig = await createQueuedMessageTestRig(options) + async function failedBeforeTheTurn(options: { tookInterrupt?: true; restartable?: true } = {}) { + rig = await createQueuedMessageTestRig(options.restartable ? { restartable: true } : {}) const sent = await rig.workingSend() const status = watchStatus() - if (!options.stopEndsSession) { + if (!options.tookInterrupt) { // Codex could not reach a turn still able to open. rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) } // The process could not be ended either. rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) expect(await rig.stop()).toMatchObject({ ok: true }) - // A Stop that ends its session ends the child on the session's next step. - await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) + await laneDrained() expect(journal().activeTurnId()).toBeNull() return { sent, status } } it.each([ - ['Codex, refused, then its kill failed', {}], - ['Claude-like, whose kill failed before the echo', { stopEndsSession: true as const }] + ['refused, then its kill failed', {}], + ['taken, whose kill failed before the echo', { tookInterrupt: true as const }] ])( 'reads Stopping through the turn that then opens, whose own end stays its own: %s', async (_, options) => { @@ -345,13 +361,23 @@ describe('a Stop pressed before its send opened a turn', () => { }) expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + await laneDrained() return { sent, status } } - it('ends the turn its interrupt took at the settle, and Stopping with it', async () => { + function turnOne() { + return journal() + .snapshot() + .items.map((item) => item.body) + .find((body) => body.kind === 'turn' && body.turnId === 'turn-1') + } + + it('ends the turn its interrupt took with the child, and Stopping with it', async () => { const { status } = await stopAsTheTurnOpens({ cancelled: true, turnId: 'turn-1' }) + expect(rig.closeSession).toHaveBeenCalledOnce() expect(journal().activeTurnId()).toBeNull() + expect(turnOne()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) await eventually(() => expect(status()?.status).toBe('idle')) expect(status()).not.toHaveProperty('stopping') }) @@ -365,15 +391,13 @@ describe('a Stop pressed before its send opened a turn', () => { await eventually(() => expect(status()?.status).toBe('idle')) expect(status()).not.toHaveProperty('stopping') // The Stop never stopped it: its own end, with no verdict, reads as a failure, not theirs. - const ended = journal() - .snapshot() - .items.map((item) => item.body) - .find((body) => body.kind === 'turn' && body.turnId === 'turn-1') - expect(ended).toMatchObject({ state: 'interrupted' }) - expect(ended).not.toHaveProperty('outcome') + expect(turnOne()).toMatchObject({ state: 'interrupted' }) + expect(turnOne()).not.toHaveProperty('outcome') }) - it('reads Stopping while the Stop settles, and Working once it settles having stopped nothing', async () => { + // Declined, the Stop still ends the child, whose end takes the send back: the send's own row says + // it never started, so the Stop writes none. + it('reads Stopping while the Stop settles, and at rest once a declined Stop ended the child', async () => { rig = await createQueuedMessageTestRig() const sent = await rig.workingSend() const status = watchStatus() @@ -384,24 +408,26 @@ describe('a Stop pressed before its send opened a turn', () => { await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) answer.resolve({ cancelled: false }) expect(await stopped).toMatchObject({ ok: true }) + await laneDrained() - // The settle's close writes no row of its own; it still reaches the status. - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') - await rig.settleAccepted(sent, 'sent') - await turn('turn-1', sent, 'running') - await eventually(() => expect(journal().activeTurnId()).toBe('turn-1')) + expect(rig.closeSession).toHaveBeenCalledOnce() + expect(await rig.submission(sent)).toMatchObject({ dispatchState: 'rejected' }) + expect(stopAnswers()).toEqual([]) + await eventually(() => expect(status()?.status).not.toBe('working')) expect(status()).not.toHaveProperty('stopping') }) it('reads a send made after the Stop as Working', async () => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true, windsDown: true }) const stopped = await rig.workingSend() const status = watchStatus() expect(await rig.stop()).toMatchObject({ ok: true }) + // The interrupted send's turn shows, ended, while the Stop's wind-down waits on it. await rig.settleAccepted(stopped, 'stopped') await turn('turn-1', stopped, 'interrupted') await eventually(() => expect(status()?.status).toBe('idle')) + await laneDrained() + expect(rig.closeSession).toHaveBeenCalledOnce() const later = await rig.workingSend() await rig.settleAccepted(later, 'later') @@ -412,9 +438,9 @@ describe('a Stop pressed before its send opened a turn', () => { }) }) -describe('a Stop whose provider ends its session', () => { +describe('a Stop whose end of the child fails', () => { it("revises the note a Stop pressed before its turn showed wrote, once that turn opened and the child's end failed", async () => { - rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + rig = await createQueuedMessageTestRig() const sent = await rig.workingSend() const status = watchStatus() const kill = Promise.withResolvers() @@ -431,7 +457,7 @@ describe('a Stop whose provider ends its session', () => { }) it("says the Stop went unconfirmed once the child's end fails, and that it took once the next Stop's joined close proves the exit", async () => { - const { status } = await runningTurn({ stopEndsSession: true }) + const { status } = await runningTurn() rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) expect(await rig.stop()).toMatchObject({ ok: true }) @@ -453,7 +479,7 @@ describe('a Stop whose provider ends its session', () => { }) it('keeps the note unconfirmed when the next Stop joins a close that fails again', async () => { - const { status } = await runningTurn({ stopEndsSession: true }) + const { status } = await runningTurn() rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) expect(await rig.stop()).toMatchObject({ ok: true }) await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) @@ -469,7 +495,7 @@ describe('a Stop whose provider ends its session', () => { // The close lives on the child, in memory, and dies with the host. The new host's settlement, // which ends the turn on a proof of the old owner's death, is what says the Stop took. async function crashAfterUnconfirmedStop(): Promise { - await runningTurn({ stopEndsSession: true }) + await runningTurn() rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) expect(await rig.stop()).toMatchObject({ ok: true }) await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) @@ -527,7 +553,7 @@ describe('a Stop whose provider ends its session', () => { // A Stop pressed before its turn showed keys its note by itself, with no turn to sit on; once it is // unconfirmed while that turn runs, the note moves onto the turn, so the turn's proven end finds it. it('moves a note a Stop wrote before its turn showed onto that turn, which then says the Stop took', async () => { - rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + rig = await createQueuedMessageTestRig() const sent = await rig.workingSend() const kill = Promise.withResolvers() rig.closeSession.mockImplementationOnce(() => kill.promise) @@ -566,7 +592,7 @@ describe('a Stop whose provider ends its session', () => { // The kill timed out, then the agent's process exits on its own: the adapter reports the end of // the close Orca began, and nothing else asks to stop. it("says the Stop took once the agent's process exits on its own after the kill timed out", async () => { - await runningTurn({ stopEndsSession: true }) + await runningTurn() rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) expect(await rig.stop()).toMatchObject({ ok: true }) await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) 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 index d0b286b24b91..dbeacad0982c 100644 --- 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 @@ -43,6 +43,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- 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 UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -63,15 +64,18 @@ let hostErrors: unknown[] let statusSink: StructuredAgentSessionStatusSink let clock: number function adapter(): StructuredAgentSessionAdapter { - return { - acquire, - closeSession, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - } + 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( 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 4af804263aec..0445b9bf6eef 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 @@ -1,7 +1,11 @@ // `agentSession.threadGoal`: change the provider thread's goal through the same // admission, ledger and journal path every other session mutation takes. -import { refuse, type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' +import { + refuse, + type AgentSessionRefusalReason, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' import type { AgentJournalItemIdentity, AgentJournalThreadGoal @@ -11,16 +15,32 @@ import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +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' function refused( reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, message: string -): TurnOutcome { +): { ok: false; refusal: AgentSessionWireRefusal } { return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } +const goalsUnsupported = () => + refused('goalsUnsupported', 'Goals are unavailable for this chat session.') + +/** The refusal for an agent with no goals; null when it has them. Needs no running agent. */ +export function refuseThreadGoalUnsupported( + adapter: Pick, + agents: Pick, + agent: string | undefined +): { ok: false; refusal: AgentSessionWireRefusal } | null { + return adapter.changeThreadGoal && agent && agents.capabilities(agent)?.threadGoal + ? null + : goalsUnsupported() +} + /** Keyed by the operation, so a replayed set upserts its one objective row. */ function objectiveIdentity(clientOperationId: string): AgentJournalItemIdentity { return { provider: 'orca', clientMessageId: `thread-goal:${clientOperationId}` } @@ -47,8 +67,11 @@ export async function performThreadGoalChange( ctx: AgentSessionTurnContext, input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { - if (!ctx.adapter.changeThreadGoal || !ctx.agents.capabilities(ctx.agent)?.threadGoal) { - return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') + if ( + !ctx.adapter.changeThreadGoal || + refuseThreadGoalUnsupported(ctx.adapter, ctx.agents, ctx.agent) + ) { + return goalsUnsupported() } const { change } = input const identity = objectiveIdentity(input.clientOperationId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 19fac5d20b8c..588f7320a83d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -4,7 +4,8 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalStatusItem, - AgentJournalSubmission + AgentJournalSubmission, + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' @@ -30,6 +31,7 @@ import type { JournalStopFailedOn } from '../agent-session-journal/queued-messag import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import { settleTakenStop } from './structured-agent-session-taken-stop-settle' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads it @@ -62,7 +64,7 @@ function stillRunsStoppedTurn( /** Whether the Stop took back every send it found in flight, none of them having run * (`withdrawCodexSendsNoTurnOpenedFor`). */ -function tookBackEverySend( +export function tookBackEverySend( ctx: Pick, inFlight: readonly AgentJournalSubmission[] ): boolean { @@ -179,9 +181,9 @@ async function cancelAndNote( !namesTurnNotLive const stoppedBefore = runningCommand && structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId) - // Read while the child is live: a provider whose Stop is a session boundary loses it next. - const endsSession = - input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true + // One rule for every agent: a chat's Stop ends its session, background work included; the next + // send resumes the conversation. A row's Stop (`scope`) stops only that task. + const endsSession = input.endSession !== undefined const stoppedAt = Date.now() // Read before the cancel: the sends a child end may take back. const sentBeforeStop = ctx.journal.submissions().filter(sendStopCanTakeBack) @@ -256,17 +258,33 @@ async function cancelAndNote( // An interrupt the provider took is worth waiting on, turn row or not: a Stop before the echo // has none, and the echo still opens the turn the Stop interrupted. binding.closedByWindDown = true + // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. + const ended: AgentJournalStatusItem | null = + note === null ? null : { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + // With no turn running, the end may take back every send, whose own rows then say they never + // started; the note waits for that end so it is never said twice. + const deferred = + ended !== null && + stoppedTurnId === null && + sentBeforeStop.length > 0 && + (stoppedTurn === undefined || !turnHasRecord(ctx, stoppedTurn)) input.endSession?.({ waitsForProvider: taken === true, stoppedAt, stopNote: noteIdentity, + ...(deferred + ? { + deferredNote: { + body: ended, + turnScope: stopNoteScope(ctx, stoppedTurn, turnScope), + unlessTakenBack: sentBeforeStop + } + } + : {}), settled: closeSettle }) cancelled = true - // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. - if (note !== null) { - note = { kind: 'status', text: 'Cancellation requested.' } - } + note = deferred ? null : ended } else if (runningCommand && !cancelled) { await input.stopChild?.() cancelled = true @@ -322,12 +340,29 @@ async function cancelAndNote( if (input.scope || note === null) { return { ok: true, value } } - // The turn the provider says the interrupt took, even one that opened while the cancel waited for - // it: the note is that turn's, never a conversation row read before the wait. - const noteScope = + await ctx.journal.appendItem(noteIdentity, note, { + fence: ctx.fence, + turnScope: stopNoteScope(ctx, stoppedTurn, turnScope) + }) + return { ok: true, value } +} + +function turnHasRecord(ctx: Pick, turnId: string): boolean { + return ctx.journal + .snapshot() + .items.some((item) => readAgentJournalTurn(item.body)?.turnId === turnId) +} + +/** The turn the provider says the interrupt took, even one that opened while the cancel waited for + * it: the note is that turn's, never a conversation row read before the wait. */ +function stopNoteScope( + ctx: Pick, + stoppedTurn: string | undefined, + fallback: AgentJournalTurnScope +): AgentJournalTurnScope { + return ( (stoppedTurn !== undefined ? structuredAgentSessionNamedTurnScope(ctx.journal, stoppedTurn) - : null) ?? turnScope - await ctx.journal.appendItem(noteIdentity, note, { fence: ctx.fence, turnScope: noteScope }) - return { ok: true, value } + : null) ?? fallback + ) } 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 17ccba7938ef..8b4474cc189e 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 @@ -151,7 +151,7 @@ export async function performSend( if (existing) { return { ok: true, - value: { clientMessageId: input.clientMessageId, submission: existing } + value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } try { @@ -276,5 +276,6 @@ function requireSubmission( if (!submission) { throw new Error('agent_session_submission_lost') } - return submission + // The answer as of this step: the journal settles its own row in place afterwards. + return { ...submission } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts index e4de581bf067..bb2610096120 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -35,8 +35,6 @@ beforeEach(async () => { rig = await createQueuedMessageTestRig({ restartable: true, starting: true, - startUnanswered: true, - stopEndsSession: true, recoveryCapsule: true }) }) @@ -83,7 +81,6 @@ describe('a message held behind a start that never answered, then the chat ends' rig = await createQueuedMessageTestRig({ restartable: true, starting: true, - stopEndsSession: true, recoveryCapsule: true }) await rig.host.close(SESSION, 'evict') 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 aae177bbd650..2a2e13e19c2a 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 @@ -52,6 +52,7 @@ import { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' +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`. */ @@ -164,7 +165,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 } | null + child: Pick | null } /** The offer one session is owed, taken right before teardown stops its provider child; null when @@ -175,8 +176,6 @@ export function structuredAgentSessionWorkingAtStop(input: { getRecord: (sessionId: string) => AgentSessionRecord | null /** The session's child records, the same read the status feed publishes. */ childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined - /** 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 @@ -190,7 +189,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 = 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-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index a2597bfd0a4d..a6b8999e1f28 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 @@ -105,7 +105,21 @@ beforeEach(async () => { location.executionHostId === 'local' && location.wslDistro === null, 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: '' }, + restoreSkippedOptions: [], + // Never admitted, so the empty report persists nothing. + optionRevision: -1 + }) return { + acquisitionGeneration, process: { hostId: 'local', pid: 4000 + acquisitions, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index ab6df634d13f..92d41bc1dd41 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -10,6 +10,7 @@ import { admitAndRunAgentSessionMutation } from './structured-agent-session-muta import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { sendPreparation } from './structured-agent-session-send-preparation' +import { runAfterProviderStart } from './structured-agent-session-provider-start-hold' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { conversationCommandBlocked } from './structured-conversation-command-admission' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -47,6 +48,17 @@ export function runStructuredConversationCommand( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, params: ConversationCommandParams +): Promise> { + // A rewind in doubt is recovered by a proven start, which the clear waits out. + return runAfterProviderStart(context, params.envelope.sessionId, () => + clearUnderSerialize(context, caller, params) + ) +} + +function clearUnderSerialize( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams ): Promise> { const { envelope, command } = params const { sessionId, clientOperationId } = envelope 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 ffaf2104b410..595c73989d5c 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 @@ -460,32 +460,12 @@ function stop(turnId: string) { }) } -it('leaves the command to the provider when it takes the Stop, and ends it as cancelled (B4)', async () => { - await attach() - const params = compactParams() - const cmid = params.envelope.clientOperationId - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - const { turnId } = structuredAgentSessionCommandTurn(cmid) - - await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - - // The interrupt was taken, not answered: the command runs until the provider ends it. - expect(state.cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId })) - expect(closeSession).not.toHaveBeenCalled() - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') - finish({ outcome: 'cancellation' }) - await vi.waitFor(async () => - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) - ) - // A cancellation writes no result row. - expect((await journal()).items.some((item) => item.itemId.includes('command-result'))).toBe(false) -}) +/** The Stop's second step, which ends the child, runs next on the session's lane. */ +function laneDrained(): Promise { + return state.host['tasks'].serialize(SESSION, async () => {}) +} -it('ends the command by stopping the child at a second Stop the provider never answered (B4)', async () => { +it('ends the command with the child after a Stop the provider took (B4)', async () => { await attach() const statuses: AgentSessionStatusEvent[] = [] state.host.subscribeStatus({ id: 'list', emit: (event) => statuses.push(event) }) @@ -495,17 +475,18 @@ it('ends the command by stopping the child at a second Stop the provider never a await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) const { turnId } = structuredAgentSessionCommandTurn(cmid) - // The provider takes the interrupt and then never answers it. - await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - expect(closeSession).not.toHaveBeenCalled() - // The chat reads Stopping, yet only the next Stop ends the command: clients keep Stop enabled. - expect(latestSummary(statuses)).toMatchObject({ status: 'working', stopping: true }) await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() - expect(state.cancelTurn).toHaveBeenCalledOnce() + // Every Stop ends the session: the provider took the interrupt, and the child's end ends the command. + expect(state.cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId })) expect(closeSession).toHaveBeenCalledOnce() - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) expect(latestSummary(statuses)).not.toHaveProperty('stopping') + // A stopped command writes no result row. + expect((await journal()).items.some((item) => item.itemId.includes('command-result'))).toBe(false) }) /** The session's newest summary in what a session list received. */ @@ -530,9 +511,12 @@ it('ends the command by stopping the child when the provider cannot take the Sto ok: true, value: { cancelled: true } }) + await laneDrained() expect(closeSession).toHaveBeenCalledOnce() - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) const notes = (await journal()).items.filter((item) => item.body.kind === 'status') expect(notes.map((item) => item.body.kind === 'status' && item.body.text)).toEqual([ 'Cancellation requested.' diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index cf43da7e5269..c7cc3b29e1a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -26,6 +26,7 @@ import { structuredAgentSessionFailureWordsContext, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +import { runAfterProviderStart } from './structured-agent-session-provider-start-hold' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' import { conversationCommandFailure, @@ -146,6 +147,18 @@ function acceptStructuredConversationCommand( ...(params.delivery ? { delivery: params.delivery } : {}), priorRecord }) + return runAfterProviderStart(context, params.envelope.sessionId, () => + acceptUnderSerialize(context, caller, params, plan, arrival) + ) +} + +function acceptUnderSerialize( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams, + plan: ReturnType, + arrival: { clearInFlight?: boolean } +): Promise> { return mutateStructuredAgentSession( context, caller, diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts index af309b13f405..aa37ca23fb4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts @@ -1,8 +1,10 @@ import { AGENT_SESSION_REWIND_REASONS, + type AgentSessionRewindParams, type AgentSessionRewindReason } from '../../../shared/agent-session-rewind' import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export function rewindRefusal(reason: AgentSessionRewindReason): { ok: false @@ -27,3 +29,20 @@ export function rewindRefusal(reason: AgentSessionRewindReason): { ) } } + +/** A rewind to a row a /clear left behind, or against an epoch since moved, is refused before any + * agent starts for it; null when the journal has no clear to check against. */ +export function rewindRefusalBehindClear( + journal: Pick | undefined, + target: Pick +): ReturnType | null { + const floor = journal?.context.floor() + if (!journal || !floor) { + return null + } + if (journal.cursor().epoch !== target.expectedEpoch) { + return rewindRefusal('stale-epoch') + } + const item = journal.snapshot().items.find((entry) => entry.itemId === target.itemId) + return !item || item.sequence <= floor.sequence ? rewindRefusal('invalid-target') : null +} 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 9b2d56aa49d4..860714b33426 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 @@ -7,6 +7,7 @@ import { createClaudeStructuredLaunchResolver } from '../claude/claude-structure import { CLAUDE_PLUGIN_DIR_FLAG, type ClaudeCliFlag } from '../claude/claude-cli-flag-support' import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' 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/pi/pi-scripted-child.test-fixture.ts b/src/main/pi/pi-scripted-child.test-fixture.ts new file mode 100644 index 000000000000..0f069f257c09 --- /dev/null +++ b/src/main/pi/pi-scripted-child.test-fixture.ts @@ -0,0 +1,249 @@ +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 + ) { + 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.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.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, + model: 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[] +} + +export const piScriptedChild = (): ScriptedPiChild => { + const children: ScriptedPiConnection[] = [] + const received: string[] = [] + const wire: string[] = [] + let resumed = 0 + let closed = 0 + 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 + ) + 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 + } + return script +} diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts index 4dd90990539f..b8038890b994 100644 --- a/src/main/pi/rpc-configured-default.test.ts +++ b/src/main/pi/rpc-configured-default.test.ts @@ -172,6 +172,7 @@ async function piHost() { } } as AgentSessionRecord) const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) + const onLifecycle = vi.fn() const adapter = new PiRpcSessionAdapter({ resolveLaunch: async () => ({ command: '/opt/pi/bin/pi', @@ -186,7 +187,7 @@ async function piHost() { child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high') return child.connection }, - onLifecycle: vi.fn(), + onLifecycle, onSettled: vi.fn(), onIdle: vi.fn(), logger: { warn: vi.fn(), error: vi.fn() } @@ -205,6 +206,10 @@ async function piHost() { options: opts.saved ?? {}, events: rig.eventSink }) + // The acquire answers at spawn; the chat is the pane's once its start is proven. + await vi.waitFor(() => + expect(onLifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + ) if (opts.afterStart && child) { await opts.afterStart({ child, adapter, sessionId }) } diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index ac78e88301a5..c4f289498818 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -32,14 +32,16 @@ function piEnvironment({ function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { const { deps } = context return new PiRpcSessionAdapter({ - resolveLaunch: createPiRpcLaunchResolver({ - store: context.store, - resolveWorkspacePath: deps.resolveWorkspacePath, - resolveEnvironment: piEnvironment(context), - ...(deps.resolveAgentCommandSettings - ? { resolveCommandSettings: deps.resolveAgentCommandSettings } - : {}) - }), + resolveLaunch: + deps.resolvePiLaunch ?? + createPiRpcLaunchResolver({ + store: context.store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment: piEnvironment(context), + ...(deps.resolveAgentCommandSettings + ? { resolveCommandSettings: deps.resolveAgentCommandSettings } + : {}) + }), ...(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 000000000000..573d28d9a678 --- /dev/null +++ b/src/main/pi/rpc-session-adapter.test-fixture.ts @@ -0,0 +1,221 @@ +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) + // Commands are read after the start; waiting for them leaves nothing of it in flight. + await vi.waitFor(() => expect(adapter.readCommands(sessionId)).toBeDefined()) + const started = reportedEvent(lifecycle, 'started') + 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 0d991d08cbe7..55d97019e1f9 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -1,184 +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 { 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 } - }) - } -} +import { + closePiRpcAdapterFixtures, + FakeConnection, + file, + sessionId, + setup +} from './rpc-session-adapter.test-fixture' -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('does not spawn a start cancelled while resolving its workspace', async () => { @@ -194,17 +27,16 @@ 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 () => { + it('closes a published child during a stalled handshake without reporting started', 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) => { @@ -216,11 +48,26 @@ describe('Pi RPC session ownership and delivery', () => { opened.resolve(connection) return connection }) - const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal }) + const acquired = h.adapter.acquire({ ...h.input, fence: 8 }) const child = await opened.promise await Promise.resolve() - controller.abort(new Error('Pi closed while starting')) - await expect(started).rejects.toThrow('closed while starting') + await expect(acquired).resolves.toMatchObject({ process: { pid: 4123 } }) + h.lifecycle.mockClear() + expect(h.adapter.readCommands(sessionId)).toBeUndefined() + expect(h.adapter.holdsDispatch(sessionId)).toBe(false) + await h.adapter.closeSession(sessionId) + await h.adapter.drainObservedExits() + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ended', + startupUnproven: true, + startupUnanswered: true, + cause: 'requested-close' + }) + ) + expect(h.lifecycle).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'started', fence: 8 }) + ) expect(child.rootVerdict).toBe('exited') }) @@ -266,7 +113,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(() => { @@ -298,12 +145,13 @@ 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.started.restoreSkippedOptions).toEqual(['unknown', 'effort']) expect(h.connection.requests).toContain('get_commands') }) @@ -575,6 +423,10 @@ describe('Pi RPC start without a model', () => { return connection }) await h.adapter.acquire({ ...h.input, fence: 8 }) + // Known by the time the start is proven: the listing is part of Pi's start. + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + ) 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)! diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 12425d684251..64b855db0284 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -16,7 +16,7 @@ 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 { applyPiRpcSessionOption } from './rpc-options' @@ -95,14 +95,14 @@ 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') } + const child = session + void child.start(launch, () => this.starts.retainFailed(id, child.connection)) return { process, - acquisitionGeneration: session.generation, - link: piRpcProviderLink(launch, file, input.fence, randomUUID(), Date.now()) + acquisitionGeneration: session.generation } } catch (error) { if (!session) { @@ -172,9 +172,6 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { await session.connection.request('abort', {}, { timeoutMs: 2_000 }) return { cancelled: true, ...(turnId ? { turnId } : {}) } } - stopEndsSession(): boolean { - return true - } awaitStoppedRequestEnd: NonNullable = async (id, at) => { const session = this.sessions.get(id) @@ -228,10 +225,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } 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 } @@ -242,10 +236,14 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { return session && !session.connection.closed && session.connection.rootVerdict !== 'exited' && - session.options?.models.length === 0 + session.startup.options?.models.length === 0 ? { reason: 'notSignedIn' } : undefined } + holdsLiveProviderProcess(id: string, generation: string): boolean { + const session = this.sessions.get(id) + return session?.generation === generation && !session.connection.closed + } async closeSession(id: string, requested = true): Promise { const session = this.sessions.get(id) 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 000000000000..e463ed9cb621 --- /dev/null +++ b/src/main/pi/rpc-session-startup.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import { AgentSessionAcquisitionExitUnprovenError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import { + closePiRpcAdapterFixtures, + file, + restart, + reportedEvent, + sessionId, + setup, + state +} from './rpc-session-adapter.test-fixture' + +afterEach(closePiRpcAdapterFixtures) + +describe('Pi background startup', () => { + it('publishes at spawn, restores model before effort, lists models in its start and reads commands after', async () => { + const h = await setup() + const handshake = Promise.withResolvers() + const listing = Promise.withResolvers() + const commands = Promise.withResolvers() + let revision = 3 + const { acquired, child } = await restart( + h, + (connection) => { + connection.requestOverride = (command) => { + if ( + command === 'get_state' && + !h.lifecycle.mock.calls.some(([event]) => event.type === 'started') + ) { + // The listing's own state read sees what the restore set. + return connection.requests.includes('get_available_models') + ? Promise.resolve({ + ...state, + model: { provider: 'anthropic', id: 'model-2' }, + thinkingLevel: 'high' + }) + : handshake.promise + } + if (command === 'get_available_models') { + return listing.promise + } + if (command === 'get_commands') { + return commands.promise + } + return command === 'set_thinking_level' ? Promise.resolve({}) : undefined + } + }, + { options: { effort: 'high', model: 'anthropic/model-2' }, optionRevision: () => revision } + ) + expect(acquired.link).toBeUndefined() + expect(child.requests).toEqual(['get_state']) + expect(h.lifecycle).not.toHaveBeenCalled() + if (!acquired.acquisitionGeneration) { + throw new Error('acquisition generation missing') + } + expect(h.adapter.holdsLiveProviderProcess(sessionId, acquired.acquisitionGeneration)).toBe(true) + revision = 5 + handshake.resolve(state) + await vi.waitFor(() => expect(child.requests).toContain('get_available_models')) + expect(child.requests.slice(0, 4)).toEqual([ + 'get_state', + 'set_model', + 'set_thinking_level', + 'get_available_models' + ]) + // The listing is part of the start: no `started` before it answers. + expect(h.lifecycle).not.toHaveBeenCalled() + listing.resolve({ models: [{ provider: 'anthropic', id: 'model-2', reasoning: true }] }) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + ) + expect(h.lifecycle.mock.calls[0]?.[0]).toMatchObject({ + type: 'started', + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration, + link: { handle: { nativeId: file }, origin: 'created' }, + reportedOptions: { + model: 'anthropic/model-2', + effort: 'high', + confirmed: ['model', 'effort'] + }, + restoreSkippedOptions: [], + optionRevision: 3 + }) + // Commands never hold the start. + expect(h.adapter.readCommands(sessionId)).toBeUndefined() + commands.resolve({ commands: [{ name: 'help' }] }) + await vi.waitFor(() => expect(h.adapter.readCommands(sessionId)).toBeDefined()) + expect(h.lifecycle).toHaveBeenCalledTimes(1) + }) + + it.each(['closeSession', 'disposeSession', 'forceCloseSession', 'releaseAcquisition'] as const)( + '%s stops a held handshake without a late started event', + async (method) => { + const h = await setup() + const handshake = Promise.withResolvers() + const { child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_state' ? handshake.promise : undefined + }) + await (method === 'releaseAcquisition' + ? h.adapter.releaseAcquisition({ sessionId }) + : h.adapter[method](sessionId)) + handshake.resolve(state) + await h.adapter.drainObservedExits() + await Promise.resolve() + expect(child.rootVerdict).toBe('exited') + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ended', startupUnproven: true, startupUnanswered: true }) + ) + expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + } + ) + + it.each([ + ['get_state', new JsonlRpcResponseError('get_state', 'Pi is not signed in'), true], + ['set_model', new Error('Agent RPC request timed out: set_model'), false], + ['set_model', new Error('Agent JSON-lines RPC stream closed'), false] + ] as const)( + 'fails startup on %s failure %s and keeps its diagnostic', + async (command, error, unanswered) => { + const h = await setup() + const { child } = await restart( + h, + (connection) => { + connection.requestOverride = (name) => + name === command ? Promise.reject(error) : undefined + }, + { options: { model: 'anthropic/model-2' } } + ) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'ended' })) + ) + expect(child.rootVerdict).toBe('exited') + const ended = reportedEvent(h.lifecycle, 'ended') + expect(ended).toMatchObject({ + cause: 'unexpected-exit', + startupUnproven: true, + reason: error.message + }) + expect(ended.startupUnanswered).toBe(unanswered ? true : undefined) + if (command === 'get_state') { + expect(ended.failure?.detail).toEqual({ text: 'Pi is not signed in', audience: 'person' }) + } + expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + } + ) + + it('does not retry acquisition while a failed start still owns an unproven process', async () => { + const h = await setup() + const { child } = await restart(h, (connection) => { + connection.closeResult = { root: 'unverifiable', tree: null } + connection.requestOverride = () => Promise.reject(new Error('get_state failed')) + }) + await vi.waitFor(() => expect(child.closed).toBe(true)) + await expect(h.adapter.acquire({ ...h.input, fence: 9 })).rejects.toBeInstanceOf( + AgentSessionAcquisitionExitUnprovenError + ) + expect(h.connections).toHaveLength(2) + child.closeResult = { root: 'exited', tree: 'exited' } + await expect(h.adapter.acquire({ ...h.input, fence: 10 })).resolves.toMatchObject({ + process: { pid: 4123 } + }) + }) + + it('preserves the provider exit diagnostic when transport closure failed startup first', async () => { + const h = await setup() + await restart(h, (connection) => { + connection.closeError = withProviderDiagnostic( + new Error('provider exited'), + providerDiagnostic('Pi is not signed in', 'log') + ) + connection.requestOverride = () => + Promise.reject(new Error('Agent JSON-lines RPC stream closed')) + }) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ended', + startupUnproven: true, + failure: { + kind: 'providerStartFailed', + detail: { text: 'Pi is not signed in', audience: 'log' } + } + }) + ) + ) + }) + + it('ends a child that exits while its handshake is held', async () => { + const h = await setup() + const { child } = await restart(h, (connection) => { + connection.requestOverride = () => new Promise(() => {}) + }) + child.exit(new Error('provider died during get_state')) + await h.adapter.drainObservedExits() + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ended', + cause: 'unexpected-exit', + startupUnproven: true, + startupUnanswered: true, + reason: 'provider died during get_state' + }) + ) + expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + }) + + it('rejects an invalid session file and reports a proved resumed file from the launch', async () => { + const h = await setup() + const { child } = await restart(h, (connection) => { + connection.requestOverride = (name) => + name === 'get_state' + ? Promise.resolve({ ...state, sessionFile: 'relative.jsonl' }) + : undefined + }) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ended', startupUnproven: true }) + ) + ) + expect(child.rootVerdict).toBe('exited') + const launch = await h.resolveLaunch.mock.results[0]?.value + if (!launch) { + throw new Error('launch missing') + } + h.resolveLaunch.mockResolvedValueOnce({ + ...launch, + sessionFile: file, + previous: h.started.link + }) + await restart(h, () => {}) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'started', + link: expect.objectContaining({ origin: 'resumed' }) + }) + ) + ) + expect(h.openConnection.mock.calls.at(-1)?.[0].args).toEqual( + expect.arrayContaining(['--session', file]) + ) + expect(reportedEvent(h.lifecycle, 'started').link?.handle.nativeId).toBe(file) + }) +}) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts new file mode 100644 index 000000000000..83f9f076a8a0 --- /dev/null +++ b/src/main/pi/rpc-session-startup.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'node:crypto' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import type { + AgentSessionOptionsResult, + AgentSessionSlashCommand +} from '../../shared/agent-session-wire' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + unpickedSessionConfiguredChoice, + type AgentModelCatalogConfiguredChoice +} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' +import { + applyPiRpcSessionOption, + piModelOptionId, + readPiRpcCommands, + readPiRpcSessionOptions +} from './rpc-options' +import { piRpcStateSchema, type PiRpcState } from './rpc-protocol' +import type { PiRpcConnection, PiRpcSessionDeps } from './rpc-session' + +export class PiRpcSessionStartup { + started = false + answered = false + commands?: AgentSessionSlashCommand[] + /** What the start listed; a signed-out Pi lists no model. */ + options?: AgentSessionOptionsResult + /** What this child started on, as its config's default; never re-read, so a later switch (an + * extension's or the user's) teaches nothing. */ + startChoice?: AgentModelCatalogConfiguredChoice | null + private readonly controller = new AbortController() + + constructor( + private readonly input: StructuredAgentSessionAcquireInput, + private readonly generation: string, + private readonly connection: PiRpcConnection, + private readonly selected: Map, + 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 + ) {} + + stop(): void { + this.controller.abort(new Error('Pi closed while starting')) + } + + async run(launch: PiRpcResolvedLaunch): Promise { + const optionRevision = this.input.optionRevision?.() ?? 0 + const answer = await this.wait(this.connection.request('get_state')) + this.answered = true + this.assertLive() + const state = piRpcStateSchema.parse(answer) + 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 } : {}) + } + const saved = this.input.options ?? {} + const skipped = Object.keys(saved).filter((key) => key !== 'model' && key !== 'effort') + // Keys a restore sent, whether or not Pi took them. + const picked = new Set() + for (const key of ['model', 'effort'] as const) { + const value = saved[key] + if (value === undefined) { + continue + } + picked.add(key) + try { + this.assertLive() + await this.wait(applyPiRpcSessionOption(this.connection, this.selected, key, value)) + current[key] = value + } catch (error) { + if (!(error instanceof JsonlRpcResponseError)) { + throw error + } + skipped.push(key) + this.warn('Pi rejected a saved option', 'pi-option-restore', error, { key }) + } + } + this.assertLive() + current.confirmed = [...(current.model ? ['model'] : []), ...(current.effort ? ['effort'] : [])] + // Part of the start, as other agents' handshakes answer with theirs: a Pi that lists no model + // is signed out, and what it started on names its config's default. + const options = await this.readListing() + this.assertLive() + if (options) { + this.options = options + this.startChoice = unpickedSessionConfiguredChoice({ + resolvesConfig: this.resolvesConfig, + picked, + ...options + }) + } + this.started = true + this.deps.onLifecycle({ + type: 'started', + ...this.identity(), + link, + reportedOptions: options?.current ?? current, + restoreSkippedOptions: skipped, + optionRevision + }) + await this.readCommands() + } + + /** Never fails the start: without it the chat only lacks the listing and the sign-in verdict. */ + private async readListing(): Promise { + try { + return await this.wait(readPiRpcSessionOptions(this.connection)) + } catch (error) { + this.assertLive() + this.warn('Pi options could not be read', 'pi-options', error) + return undefined + } + } + + private async readCommands(): Promise { + try { + this.assertLive() + const result = await this.wait(readPiRpcCommands(this.connection)) + this.assertLive() + this.commands = result.commands + } catch (error) { + this.warn('Pi commands could not be read', 'pi-commands', error) + } + } + + private assertLive(): void { + if (this.controller.signal.aborted || this.connection.closed) { + throw new Error('Pi closed while starting') + } + } + + private wait(promise: Promise): Promise { + return waitForPromiseWithSignal(promise, this.controller.signal) + } + + private identity() { + return { + sessionId: this.input.identity.sessionId, + fence: this.input.fence, + acquisitionGeneration: this.generation + } + } + + private warn(message: string, scope: string, error: unknown, fields = {}): void { + if (!this.controller.signal.aborted) { + 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 13f61f1fc979..63d77914ddd6 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,13 @@ 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 AgentModelCatalogConfiguredChoice -} 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 { PiRpcSessionStartup } from './rpc-session-startup' export type PiRpcConnection = Pick< JsonlRpcAgentConnection, @@ -62,12 +61,7 @@ export class PiRpcSession { readonly turns: PiRpcTurns readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() - /** What this child started on, as its config's default; never re-read, so a later switch (an - * extension's or the user's) teaches nothing. */ - private startChoice?: AgentModelCatalogConfiguredChoice | null - readonly skipped: string[] = [] - commands?: AgentSessionSlashCommand[] - options?: AgentSessionOptionsResult + readonly startup: PiRpcSessionStartup requestedClose = false private publishedExit = false private failedCause?: Error @@ -147,56 +141,44 @@ export class PiRpcSession { .finally(() => this.releaseAfterExit?.()) } }) + this.startup = new PiRpcSessionStartup( + input, + generation, + this.connection, + this.selected, + deps, + resolvesConfig, + (state) => { + this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) + } + ) } - 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', - sessionId: this.input.identity.sessionId, - key, - error - }) - } - } - this.options = await readPiRpcSessionOptions(this.connection) - this.startChoice = unpickedSessionConfiguredChoice({ - resolvesConfig: this.resolvesConfig, - picked, - ...this.options - }) + async start(launch: PiRpcResolvedLaunch, retainFailed: () => void): Promise { try { - this.commands = (await readPiRpcCommands(this.connection)).commands + await this.startup.run(launch) } catch (error) { - this.deps.logger.warn('Pi commands could not be read', { - scope: 'pi-commands', - sessionId: this.input.identity.sessionId, - error - }) + if (!this.requestedClose) { + this.failedCause ??= error instanceof Error ? error : new Error(String(error)) + } + const result = await this.close(false).catch(() => null) + if (result?.root !== 'exited') { + retainFailed() + } } - return state.sessionFile } /** What the child reports now, with what its start said of its config's default. */ async readOptions() { - return withLiveCatalogListing(await readPiRpcSessionOptions(this.connection), this.startChoice) + return withLiveCatalogListing( + await readPiRpcSessionOptions(this.connection), + this.startup.startChoice + ) } async close(requested = true): ReturnType { this.requestedClose ||= requested + this.startup.stop() this.dialogs.cancelAll() return this.connection.close() } @@ -250,6 +232,8 @@ export class PiRpcSession { return } this.publishedExit = true + this.failedCause ??= error + this.startup?.stop() this.dialogs.cancelAll() this.turns.end() this.lane.finalize() @@ -275,6 +259,7 @@ export class PiRpcSession { } finally { clearTimeout(timer) } + const cause = this.failedCause ?? error this.deps.onLifecycle({ type: 'ended', sessionId: this.input.identity.sessionId, @@ -282,7 +267,20 @@ 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( + this.startup.started ? 'providerExited' : 'providerStartFailed', + { + detail: + providerDiagnosticOf(cause) ?? + providerDiagnosticOf(error) ?? + (cause instanceof JsonlRpcResponseError + ? providerDiagnostic(cause.message, 'person') + : undefined) + } + ), + ...(this.startup.started ? {} : { startupUnproven: true }), + ...(this.startup.answered ? {} : { startupUnanswered: true }) }) } diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 68db039d6ce1..ec400dd5c522 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -81,8 +81,7 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( record.lease.claimStatus !== 'live' || record.lease.handoffStage !== null || record.lease.ownerProcess?.spawnToken !== args.spawnToken || - record.lease.reservedSpawnToken !== args.spawnToken || - record.lease.provenHandleLinkId === null + record.lease.reservedSpawnToken !== args.spawnToken ) { throw new Error('agent_session_ownership_unknown') } diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 950e05a8e78e..8a1dec6f13b2 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -125,13 +125,14 @@ export function commitAgentSessionProcessIdentity( } /** - * The new runtime proved it resumed the expected provider handle. Only now does the session have - * a writer. + * The new runtime's process owns the session: only now does it have a writer. `link` is the + * provider handle when the acquire already held it; one the provider answers with later is recorded + * through `recordAgentSessionProviderHandle`, and until then the lease names no proven link. */ export function proveAgentSessionOwner(args: { record: AgentSessionRecord fence: number - link: AgentSessionProviderHandleLink + link?: AgentSessionProviderHandleLink now: number leaseTtlMs: number }): AgentSessionRecord { @@ -146,18 +147,18 @@ export function proveAgentSessionOwner(args: { reason: 'spawnIdentityMismatch' }) } - if (!agentSessionProviderHandleBelongsTo(args.link.handle, record.provider)) { + const { link } = args + if (link && !agentSessionProviderHandleBelongsTo(link.handle, record.provider)) { throw new Error('agent_session_provider_handle_provider_mismatch') } - if (args.link.mintedAtFence !== args.fence) { + if (link && link.mintedAtFence !== args.fence) { throw new Error('agent_session_provider_handle_stale_fence') } - const providerHandleChain = appendAgentSessionProviderHandleLink( - record.providerHandleChain, - args.link - ) + const providerHandleChain = link + ? appendAgentSessionProviderHandleLink(record.providerHandleChain, link) + : record.providerHandleChain const head = providerHandleChain.at(-1) - if (!head) { + if (link && !head) { throw new Error('agent_session_provider_handle_invalid') } return { @@ -166,7 +167,7 @@ export function proveAgentSessionOwner(args: { lease: { ...record.lease, handoffStage: null, - provenHandleLinkId: head.linkId, + provenHandleLinkId: link && head ? head.linkId : null, claimStatus: 'live', leaseDeadlineAt: args.now + args.leaseTtlMs, lastRenewedAt: args.now, diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index bef2bc424efa..a0fa4451015e 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -3,7 +3,8 @@ 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 { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' import { openTestAgentSessionRecordStore, seedTestAgentSessionRecordStore @@ -25,43 +26,24 @@ 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, +it('drops provider-rejected persisted options from what a started child reports', () => { + expect( + nativeSessionOptionsFromReport({ + reported: { model: 'provider-model' }, + restoreSkipped: ['permissionMode'], priorOptions: { permissionMode: 'retired-mode', other: 'keep' } }) - ).resolves.toEqual({ model: 'provider-model', other: 'keep' }) + ).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, +it('omits empty model values from other option producers', () => { + expect( + nativeSessionOptionsFromReport({ + reported: { model: '', effort: 'off' }, + restoreSkipped: [], priorOptions: { model: 'old-model', other: 'keep' } }) - ).resolves.toEqual({ effort: 'off', other: 'keep' }) + ).toEqual({ effort: 'off', other: 'keep' }) }) it('durably replaces model-less ACP options when a later report reaches the option writer', async () => { @@ -100,15 +82,12 @@ it('durably replaces model-less ACP options when a later report reaches the opti 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 + const prior = store.getRecord(record.sessionId)?.options + const options = nativeSessionOptionsFromReport({ + reported: reader.read().current, + restoreSkipped: [], + ...(prior ? { priorOptions: prior } : {}) }) - if (!options) { - throw new Error('The later ACP report must provide options') - } await store.replaceSessionOptions({ sessionId: record.sessionId, fence: record.lease.runtimeFence, @@ -122,7 +101,7 @@ it('durably replaces model-less ACP options when a later report reaches the opti }) }) -it('persists resumed provider options atomically with owner proof', async () => { +it('makes the process the owner before its provider answers with a handle, and records it after', async () => { const store = await openTestAgentSessionRecordStore(directory) const reserved = await store.reserveOwner({ sessionId: SESSION, @@ -158,30 +137,34 @@ it('persists resumed provider options atomically with owner proof', async () => }, now: NOW }) - const options = await readNativeSessionOptions({ - adapter: { - readOptions: async () => ({ - models: [], - current: { model: 'gpt-tui', effort: 'low' } - }) - }, - sessionId: SESSION, - fence - }) - await store.proveOwner({ + await store.proveOwner({ sessionId: SESSION, fence, now: NOW }) + await store.replaceSessionOptions({ sessionId: SESSION, fence, - link: { - linkId: 'codex-options-1', - handle: codexProviderHandle('thread-options'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW, - ...(options ? { options } : {}) + options: { model: 'gpt-tui' }, + now: NOW }) + // A crash here leaves a record the next run reads back: live, its handle still owed. + const starting = (await openTestAgentSessionRecordStore(directory)).getRecord(SESSION) + expect(starting?.lease).toMatchObject({ claimStatus: 'live', provenHandleLinkId: null }) + expect(starting?.providerHandleChain).toEqual([]) + expect(starting?.options).toEqual({ model: 'gpt-tui' }) - const reopened = await openTestAgentSessionRecordStore(directory) - expect(reopened.getRecord(SESSION)?.options).toEqual({ model: 'gpt-tui', effort: 'low' }) + await store.transitionHandoff(SESSION, (record) => + recordAgentSessionProviderHandle({ + record, + fence, + link: { + linkId: 'codex-options-1', + handle: codexProviderHandle('thread-options'), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + ) + const started = (await openTestAgentSessionRecordStore(directory)).getRecord(SESSION) + expect(started?.lease.provenHandleLinkId).toBe('codex-options-1') + expect(started?.providerHandleChain.map((link) => link.linkId)).toEqual(['codex-options-1']) }) diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index cc0a83cc55f2..f9bb8f799dd6 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -730,9 +730,10 @@ describe('claim keys and unreadable rows', () => { Object.assign(record.lease, { journalCheckpoint: { epoch: 'bad', sequence: 1 } }) ], [ - 'missing live proof', + // No proof yet is a live owner whose provider has not answered; a proof naming no link is not. + 'live proof naming no handle', (record: PersistedAgentSessionRecord) => - Object.assign(record.lease, { provenHandleLinkId: null }) + Object.assign(record.lease, { provenHandleLinkId: 'link-never-minted' }) ] ])('quarantines a record with %s', async (_name, corrupt) => { const first = await open() diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 9cca024aca1b..49d333ce3f53 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -208,23 +208,19 @@ export class AgentSessionRecordStore { async proveOwner(args: { sessionId: string fence: number - link: AgentSessionProviderHandleLink + 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, - link: args.link, + ...(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-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index f4d63c020040..b13e6da15c8b 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 @@ -125,7 +125,6 @@ describe('a Claude start whose CLI answers initialize but not a control request' await vi.waitFor(() => expect(record(host)?.options).toEqual({ ...saved, effort: 'high' }), { timeout: DEADLINE_MS * 40 }) - 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') diff --git a/src/main/runtime/orchestration/send-agent-turn.test.ts b/src/main/runtime/orchestration/send-agent-turn.test.ts index bd90030a8bc4..bc67c7a31779 100644 --- a/src/main/runtime/orchestration/send-agent-turn.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn.test.ts @@ -135,6 +135,17 @@ describe('sendAgentTurn to a structured session', () => { }) }) + it("bounds the wait, the agent's start included, by the caller's own budget", async () => { + const fake = structuredHost( + accepted({ clientMessageId: 'op-1', submission: submissionOf('pending') }) + ) + // The start outlasts a --timeout of 5 s: the answer stays pending, and the host still holds it. + await expect( + sendAgentTurn({ ...structured(fake.host), budgetMs: 5_000 }) + ).resolves.toMatchObject({ kind: 'sent', submission: { dispatchState: 'pending' } }) + expect(fake.waitForSendSettlement.mock.calls).toEqual([['s1', 'op-1', { budgetMs: 5_000 }]]) + }) + it('keeps the first answer when the wait runs out or fails', async () => { for (const settled of [undefined, 'throws'] as const) { const fake = structuredHost( diff --git a/src/main/runtime/orchestration/send-agent-turn.ts b/src/main/runtime/orchestration/send-agent-turn.ts index 9805a346b7ee..b7a24c4c4365 100644 --- a/src/main/runtime/orchestration/send-agent-turn.ts +++ b/src/main/runtime/orchestration/send-agent-turn.ts @@ -50,6 +50,9 @@ export type StructuredSessionTurnSend = { /** Scopes the host's operation ledger, so one sender's sends cannot exhaust another's budget. */ callerKey: string turn: StructuredSessionTurn + /** The caller's own readiness budget for the agent to take the message, its start included; + * the orchestration readiness timeout by default. */ + budgetMs?: number } /** @@ -139,8 +142,9 @@ async function sendStructuredSessionTurn( if ('queued' in result.value) { return { kind: 'queued', clientMessageId, queued: result.value.queued } } - // Accepted is not delivered: the agent may still be starting, so wait the start out. A wait - // that fails or runs out leaves the first answer standing. + // Accepted is not delivered: the agent may still be starting. One budget, the caller's, covers + // the start and the answer; a start that outlasts it leaves the message held for the agent, and + // the answer `pending`, which the caller reports as a turn start nobody observed yet. const answered = agentSessionSendSubmission(result.value) if (answered?.dispatchState !== 'pending') { return { kind: 'sent', clientMessageId, submission: answered } @@ -149,7 +153,7 @@ async function sendStructuredSessionTurn( // hand-off, which the queue sent under a fresh id. const settled = await send.host .waitForSendSettlement(send.sessionId, answered.clientMessageId, { - budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + budgetMs: send.budgetMs ?? ORCHESTRATION_READINESS_TIMEOUT_MS }) .catch(() => undefined) return { diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 68120a5ad359..baa48a0b281b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -3,8 +3,10 @@ * * Three things make this different from the PTY worker path, and all three live here: * - * - The session is created directly as structured, so readiness is the attach returning ok. There - * is no boot-to-idle gap to wait on and no `tui-idle` edge to read. + * - The session is created directly as structured. Its attach returns while the agent is still + * starting, so attachment is not readiness: the preamble goes through the host's hold + * (`sendAgentTurn` waits the start out), and the worker is ready once its agent took it. There + * is no `tui-idle` edge to read. * - Nothing here keeps its agent running. The idle sweep leaves it running while its dispatch is * open, reading that from the orchestration database; once the dispatch settles the agent rests * like any chat's, and the next mail starts it. @@ -236,6 +238,8 @@ export async function sendStructuredWorkerPreamble(args: { preamble: string /** Who the task is from (`dispatchTaskSource`), shown on the worker's turn. */ from: AgentMessageSource + /** The worker start's readiness budget, which the agent's start counts against. */ + budgetMs?: number }): Promise<'accepted' | 'pending'> { const body: AgentJournalMessageItem = { kind: 'message', @@ -252,6 +256,7 @@ export async function sendStructuredWorkerPreamble(args: { host: args.host, sessionId: args.sessionId, callerKey: structuredPointerCallerKey(args.dispatchId), + ...(args.budgetMs === undefined ? {} : { budgetMs: args.budgetMs }), turn: { body, delivery: 'now', diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-deadline.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-deadline.test.ts new file mode 100644 index 000000000000..7948feb49304 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-deadline.test.ts @@ -0,0 +1,110 @@ +// A structured worker's setup gate and its preamble's wait for the agent share one deadline: the +// caller's timeout. Waiting the full timeout again after a slow setup outlasts the client's limit. + +import { afterEach, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' + +const preambleBudgets: (number | undefined)[] = [] + +vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ({ + ...(await importOriginal>()), + sendStructuredWorkerPreamble: async (args: { budgetMs?: number }) => { + preambleBudgets.push(args.budgetMs) + return 'accepted' + } +})) +vi.mock('./orchestration/worker/worker-start-agent-placement', async (importOriginal) => ({ + ...(await importOriginal>()), + placeWorkerAgent: async (args: { mode: unknown; effects: unknown[] }) => { + // The setup a created worktree runs, with the repo's wait-for-setup policy. + args.effects.splice(0, args.effects.length, { + kind: 'setup', + action: 'started', + terminalId: 'setup_1' + }) + return { + mode: args.mode, + worktree: { id: 'wt_1' }, + terminalHandle: 'structured_worker_1', + structuredSession: { host: {}, identity: { sessionId: 's1', handle: 'structured_worker_1' } }, + setupReceipt: { startupPolicy: 'wait-for-setup', state: 'running' } + } + } +})) +vi.mock('./orchestration/worker/worker-start-validation', () => ({ + prepareLocalWorkerStart: () => ({ + agent: 'claude', + launch: { receipt: { requested: null, effective: null }, preferences: undefined } + }) +})) +vi.mock('./orchestration/worker/worker-setup-gate', () => ({ + persistGatedSetupSpawnFailure: () => false, + persistWorkerReadinessStage: () => {}, + persistWorkerSetupWaitOutcome: () => {} +})) +vi.mock('./orchestration/worker/worker-topology', async (importOriginal) => ({ + ...(await importOriginal>()), + monitorWorkerSetup: () => {} +})) +vi.mock('./orchestration/runs/dispatch-creator', () => ({ + resolveDispatchCreator: () => ({ kind: 'terminal', handle: 'term_c' }) +})) +vi.mock('../../orchestration/preamble', () => ({ buildDispatchPreamble: () => 'preamble' })) + +const { startLocalWorker } = await import('./orchestration/worker/local-worker-start') + +afterEach(() => { + vi.useRealTimers() + preambleBudgets.length = 0 +}) + +it('leaves the preamble only what the setup gate did not use of the caller’s timeout', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const runtime = { + showTerminal: async () => ({ worktreeId: 'wt_1' }), + showManagedTerminalWorkspace: async () => ({ id: 'wt_1' }), + getNestedWorkerMaxDepth: () => 3, + getRuntimeId: () => 'epoch-1', + getTerminalOrchestrationCliCommand: () => 'orca', + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + getOrchestrationDispatchAuthority: () => ({ paneKey: 'pane', processIncarnation: 'inc' }), + getTerminalProcessIncarnation: () => 'inc', + // Setup takes 250 s of the 300 s timeout. + waitForSetupTerminalCompletion: async () => { + vi.setSystemTime(Date.now() + 250_000) + return { exitCode: 0 } + } + } + const db = { + createStartingWorkerDispatch: () => ({ + dispatch: { id: 'd1', depth: 0 }, + task: { id: 't1', spec: 'do the thing' } + }), + recordWorkerStage: () => {}, + prepareStartingWorkerAuthority: () => {}, + getWorkerDispatch: () => ({ state: 'starting' }), + markWorkerDispatchReady: () => ({ state: 'ready', stage: 'ready' }) + } + + const receipt = await startLocalWorker({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fields this start reads; the rest are optional. + params: { from: 'term_c', timeoutMs: 300_000, agent: 'claude' } as never, + mode: { + mode: 'structured', + preferred: 'structured', + reason: 'user_default', + detail: 'structured by default' + } as const, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the start path reaches only these runtime members. + runtime: runtime as unknown as OrcaRuntimeService, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the start path reaches only these db members. + db: db as unknown as OrchestrationDb, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the run id is read. + run: { id: 'run_1' } as never, + coordinator: null + }) + + expect(receipt).toMatchObject({ state: 'ready', timeoutMs: 300_000 }) + expect(preambleBudgets).toEqual([50_000]) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index 5fce3402cc5f..3602dbb1928d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -46,6 +46,8 @@ export async function deliverWorkerDispatchPreamble(args: { requestId: string /** The agent this worker start launched into `terminalHandle`; absent for a caller's terminal. */ launchedAgent?: string | null + /** The caller's readiness budget; a structured worker's start counts against it. */ + readinessTimeoutMs?: number }): Promise<{ prompt?: RuntimeTerminalSend['prompt'] structuredTurnStart?: WorkerTurnStartObservation @@ -86,7 +88,8 @@ export async function deliverWorkerDispatchPreamble(args: { sessionId: structuredSession.identity.sessionId, dispatchId: args.dispatchId, preamble, - from: workerTaskSource(args) + from: workerTaskSource(args), + ...(args.readinessTimeoutMs === undefined ? {} : { budgetMs: args.readinessTimeoutMs }) }) return { structuredTurnStart: diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index b46e8d744265..2340f4ba79bc 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -205,8 +205,7 @@ export async function startLocalWorker(args: { mode = placed.mode resolvedWorktree = placed.worktree terminalHandle = placed.terminalHandle - const structuredSession = placed.structuredSession - const setupReceipt = placed.setupReceipt + const { structuredSession, setupReceipt } = placed const setupStage = { db, dispatchId: started.dispatch.id, @@ -223,15 +222,16 @@ export async function startLocalWorker(args: { persistWorkerReadinessStage(setupStage) failedStage = 'agent_readiness' - // A structured session is ready the moment its attach returns ok: there is no boot-to-idle - // gap and no terminal title to read an idle edge from. Only the repo's wait-for-setup policy - // still holds it back, and that gate has to be waited on explicitly here. + // A structured worker's setup gate and its preamble's wait share one deadline: the timeout. + const readinessDeadline = Date.now() + (params.timeoutMs ?? 60_000) + // A structured session has no boot-to-idle gap or terminal title to read: its agent's start is + // waited out by the preamble's send. Only the repo's wait-for-setup policy is waited on here. const wait = structuredSession ? await awaitStructuredWorkerSetupGate({ runtime, setup: setupReceipt, effects, - timeoutMs: params.timeoutMs ?? 60_000 + timeoutMs: Math.max(0, readinessDeadline - Date.now()) }) : chat ? // A chat takes its task as a queued send whatever it is doing, so nothing is waited on. @@ -288,6 +288,7 @@ export async function startLocalWorker(args: { launchReceipt: launch.receipt, mode, timeoutMs: params.timeoutMs ?? 60_000, + readinessDeadline, effects, terminalRevealWarning: placed.warning, onStage: (stage) => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts index ceeae2710d4d..dca7561abe45 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -49,6 +49,7 @@ function settle(delivered: 'accepted' | undefined) { launchReceipt: {}, mode: {}, timeoutMs: 60_000, + readinessDeadline: Date.now() + 60_000, effects: [], terminalRevealWarning: undefined, onStage: () => {} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index 87466499afe3..779634834773 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -40,7 +40,10 @@ export async function deliverAndSettleWorkerStartReadiness(args: { setupReceipt: WorkerSetupReceipt launchReceipt: OrchestrationWorkerLaunchReceipt mode: WorkerStartModeReceipt + /** The caller's timeout, as the receipt reports it. */ timeoutMs: number + /** When the caller's timeout runs out; a structured preamble waits on its agent until then. */ + readinessDeadline: number effects: WorkerEffect[] terminalRevealWarning: string | undefined /** Keeps the caller's failure receipt naming the stage that actually failed. */ @@ -62,7 +65,8 @@ export async function deliverAndSettleWorkerStartReadiness(args: { coordinatorHandle: args.coordinatorHandle, devMode: args.devMode, requestId: args.requestId, - launchedAgent: args.launchedAgent + launchedAgent: args.launchedAgent, + readinessTimeoutMs: Math.max(0, args.readinessDeadline - Date.now()) }) effects.push({ kind: 'dispatch_input', diff --git a/src/main/runtime/structured-agent-registration-lifecycle-matrix.test.ts b/src/main/runtime/structured-agent-registration-lifecycle-matrix.test.ts new file mode 100644 index 000000000000..22e77b9a9d02 --- /dev/null +++ b/src/main/runtime/structured-agent-registration-lifecycle-matrix.test.ts @@ -0,0 +1,301 @@ +// Every agent this build registers follows the same startup and Stop rules, driven through its own +// real adapter, the shipped runtime, host, store and journal, with only the provider child scripted. +// Iterates the runtime's registration list, so an agent registered later fails here until it has a +// scripted child, rather than going untested. + +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 { AgentSessionAccountHome } from '../../shared/agent-session-account-home' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { ACP_LAUNCH_SPECS } from '../acp/acp-launch-specs' +import { acpScriptedChild } from '../acp/acp-scripted-child.test-fixture' +import { claudeScriptedChild } from '../claude/claude-scripted-child.test-fixture' +import { codexScriptedChild } from '../codex/codex-scripted-child.test-fixture' +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + 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 { piScriptedChild } from '../pi/pi-scripted-child.test-fixture' +import { sendStructuredWorkerPreamble } from './rpc/methods/orchestration-structured-worker-session' +import type { + ScriptedAgentChild, + ScriptedAgentChildFactory +} from './structured-agent-scripted-child.test-fixture' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from './structured-agent-runtime-registrations' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime, + type StructuredAgentSessionRuntimeDeps +} from './structured-agent-session-runtime' + +// Every ACP agent runs the one ACP adapter, so each gets the ACP child; any other kind needs its own. +const SCRIPTED_CHILDREN: Readonly> = { + claude: claudeScriptedChild, + codex: codexScriptedChild, + ...Object.fromEntries(ACP_LAUNCH_SPECS.map(({ agent }) => [agent, acpScriptedChild(agent)])), + pi: piScriptedChild +} + +const SESSION = 'matrix-session' +const CALLER = { callerKey: 'matrix-client' } + +let root: string | null = null +let operations = 0 + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +const eventually = (assertion: () => void | Promise): Promise => + vi.waitFor(assertion, { timeout: 10_000 }) + +/** The runtime admits operation ids against its own clock. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function accountHome(definition: StructuredAgentDefinition, home: string): AgentSessionAccountHome { + return definition.accountLocatorKind === 'opencode' + ? { kind: 'opencode', locator: { kind: 'unmanaged' } } + : { variable: definition.accountHomeVariable, path: join(home, definition.agent) } +} + +type Chat = { + host: StructuredAgentSessionHost + child: ScriptedAgentChild + fence: () => number + send: (text: string) => Promise + stop: () => Promise + submission: (clientMessageId: string) => Promise + phase: () => 'starting' | 'ready' | undefined +} + +/** A new chat of `definition`'s agent, attached while its first handshake is held. */ +async function openChat( + definition: StructuredAgentDefinition, + runtime: Partial = {} +): Promise { + const factory = SCRIPTED_CHILDREN[definition.agent] + if (!factory) { + throw new Error(`${definition.agent} is registered but has no scripted child for this matrix`) + } + const child = factory() + root = await mkdtemp(join(tmpdir(), `orca-lifecycle-matrix-${definition.agent}-`)) + const directory = root + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: directory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => directory, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'system' }), + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + ...child.deps, + ...runtime + }) + const fence = (): number => host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + const attachParams = hostTestAttachParams(null, { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + provider: definition.agent, + agent: definition.agent, + accountHome: accountHome(definition, directory), + providerHandle: undefined + }) + const attached = await host.attach(CALLER, attachParams) + expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + // A create whose reply was lost is retried under the same operation: no second agent starts. + expect(await host.attach(CALLER, attachParams)).toMatchObject({ ok: true }) + expect(child.spawns()).toBe(1) + const envelope = (method: string, fields: Record) => ({ + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + }) + return { + host, + child, + fence, + send: async (text) => { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId + }, + stop: async () => { + const stopped = await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + expect(stopped, JSON.stringify(stopped)).toMatchObject({ ok: true }) + }, + submission: async (clientMessageId) => { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + }, + phase: () => host.readStatusSummary(SESSION)?.hostExecutionPhase + } +} + +describe.each( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + ({ definition }) => [definition.agent, definition] as const + ) +)('%s, through its own adapter', (_agent, definition) => { + it('publishes at spawn, takes a send while starting, and delivers it once started', async () => { + const chat = await openChat(definition) + expect(chat.phase()).toBe('starting') + const sent = await chat.send('hello while starting') + expect((await chat.submission(sent))?.dispatchState).toBe('pending') + expect(chat.child.prompts()).toEqual([]) + + chat.child.releaseHandshake() + + await eventually(() => expect(chat.child.prompts()).toEqual(['hello while starting'])) + expect(chat.phase()).toBe('ready') + // The handle the provider answered with is what a later start resumes. + expect(chat.host.deps.store.getRecord(SESSION)?.lease.provenHandleLinkId).not.toBeNull() + }) + + it('becomes ready without a first message', async () => { + const chat = await openChat(definition) + chat.child.releaseHandshake() + await eventually(() => expect(chat.phase()).toBe('ready')) + expect(chat.child.prompts()).toEqual([]) + }) + + it('settles a message held through a failed start as never sent, and starts again on the next', async () => { + const chat = await openChat(definition) + const held = await chat.send('lost start') + chat.child.failHandshake('the agent could not start') + + await eventually(async () => + expect((await chat.submission(held))?.dispatchState).toBe('rejected') + ) + expect(chat.child.prompts()).toEqual([]) + + chat.child.holdHandshakes = false + await chat.send('after the failure') + await eventually(() => expect(chat.child.prompts()).toEqual(['after the failure'])) + expect(chat.child.spawns()).toBe(2) + }) + + it('withdraws what it held when stopped while starting, and resumes nothing it never ran', async () => { + const chat = await openChat(definition) + const held = await chat.send('stopped before the start') + await chat.stop() + + await eventually(async () => + expect((await chat.submission(held))?.dispatchState).toBe('rejected') + ) + await eventually(() => expect(chat.child.closes()).toBe(1)) + expect(chat.child.prompts()).toEqual([]) + }) + + it('ends the session on Stop, background work and all, and resumes the conversation on the next message', async () => { + const chat = await openChat(definition) + chat.child.holdHandshakes = false + chat.child.releaseHandshake() + chat.child.completeTurns = false + await chat.send('long running work') + await eventually(() => expect(chat.child.prompts()).toEqual(['long running work'])) + + await chat.stop() + + await eventually(() => expect(chat.child.closes()).toBe(1)) + await eventually(() => expect(chat.phase()).toBeUndefined()) + chat.child.completeTurns = true + await chat.send('after the Stop') + await eventually(() => + expect(chat.child.prompts()).toEqual(['long running work', 'after the Stop']) + ) + expect(chat.child.spawns()).toBe(2) + expect(chat.child.resumes()).toBe(1) + }) + + it('stops a start that goes silent past its limit, failing what it held, and starts again on the next', async () => { + const chat = await openChat(definition, { startupLimits: { silenceMs: 200 } }) + const held = await chat.send('held through a silent start') + + await eventually(async () => + expect((await chat.submission(held))?.dispatchState).toBe('rejected') + ) + await eventually(() => expect(chat.child.closes()).toBe(1)) + expect(chat.child.prompts()).toEqual([]) + + chat.child.holdHandshakes = false + await chat.send('after the silent start') + await eventually(() => expect(chat.child.prompts()).toEqual(['after the silent start'])) + }) + + it("answers a worker's preamble pending once the caller's budget runs out, and delivers it when the agent starts", async () => { + const chat = await openChat(definition) + const preamble = await sendStructuredWorkerPreamble({ + ...workerPreamble(chat), + budgetMs: 50 + }) + expect(preamble).toBe('pending') + expect(chat.child.prompts()).toEqual([]) + + chat.child.releaseHandshake() + + await eventually(() => expect(chat.child.prompts()).toEqual(['worker preamble'])) + }) + + it("holds an orchestration worker's preamble until the agent has started", async () => { + const chat = await openChat(definition) + let settled = false + const preamble = sendStructuredWorkerPreamble(workerPreamble(chat)).finally(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(settled).toBe(false) + expect(chat.child.prompts()).toEqual([]) + + chat.child.releaseHandshake() + + await expect(preamble).resolves.toMatch(/^(accepted|pending)$/) + expect(chat.child.prompts()).toEqual(['worker preamble']) + }) +}) + +function workerPreamble(chat: Chat): Parameters[0] { + return { + host: chat.host, + sessionId: SESSION, + dispatchId: 'ctx_matrix', + preamble: 'worker preamble', + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_coord', terminalHandle: 'term_coord', orcaSessionId: null }, + name: 'Coordinator' + } + ], + orchestration: { message: 'task', runId: 'r1', taskId: 't1', dispatchId: 'ctx_matrix' } + } + } +} diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index ef919351db4f..ac5a0309763a 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -162,8 +162,11 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA onPrimaryThreadStoppedRunning: followUps.releaseUnansweredDispatches, logger: deps.logger, onEvent: (event) => { - // Every exit, expected or not: the host ends that child's record. - if (event.type === 'ended' && 'cause' in event) { + if ( + event.type === 'started' || + event.type === 'options-reported' || + (event.type === 'ended' && 'cause' in event) + ) { context.deliverLifecycle(event) } } @@ -233,6 +236,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio resolveLaunch: createAcpStructuredLaunchResolver(spec, { store, readJournal, + ...deps.acpLaunchCommand, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveBaseEnvironment, ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), @@ -245,18 +249,13 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio ...nativeChatVisualsFor(deps), logger: deps.logger }), - connect: (launch, options) => createAcpAgentConnection(launch, options), + connect: deps.openAcpConnection ?? createAcpAgentConnection, onChildWorkEvidence: (sessionId, evidence) => context.host()?.publishChildWorkEvidence(sessionId, evidence), ...(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 000000000000..4a13eac23236 --- /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-codex-compact-exit.test.ts b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts index ee3876301df7..4e9b63c70669 100644 --- a/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts +++ b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts @@ -146,8 +146,11 @@ it('ends a command Codex has not opened a turn for by stopping its child, and th expect(first.closed).toBe(true) expect(first.calls.some((entry) => entry.method === 'turn/interrupt')).toBe(false) - const turn = (await snapshot()).items.find((item) => item.itemId === itemId) - expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + // The child's end, the Stop's second step, settles the command's turn. + await vi.waitFor(async () => { + const turn = (await snapshot()).items.find((item) => item.itemId === itemId) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + }) await send('after the stop') await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) expect(calls('turn/start')[0]).not.toBe(first) diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index a1095f28ccc9..2408b0f6be02 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -96,6 +96,7 @@ async function stop(turnId?: string): Promise { if (!stopped.ok) { throw new Error(JSON.stringify(stopped.refusal)) } + await host.collaboratorsForTests().serialize(SESSION, async () => {}) } /** Every end row written for turn-1, as a row or a lifecycle batch's mutation, in order. */ 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 6e3e94a3fa14..033682d765b2 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 @@ -123,6 +123,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. */ 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 000000000000..1fe8248cb853 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -0,0 +1,158 @@ +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(child.prompts()).toEqual([]) + child.releaseHandshake() + await vi.waitFor(() => expect(child.prompts()).toEqual(['held until Codex is ready'])) +}) + +it('ends a running Codex child on Stop and resumes its thread for the next send', async () => { + const child = codexScriptedChild() + child.holdHandshakes = false + child.completeTurns = false + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('first turn') + expect( + (await host.send(caller, { envelope: envelope('agentSession.send', fence, { body }), body })).ok + ).toBe(true) + await vi.waitFor(() => expect(child.prompts()).toEqual(['first turn'])) + 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) + const next = hostTestMessage('next turn') + expect( + ( + await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body: next }), + body: next + }) + ).ok + ).toBe(true) + await vi.waitFor(() => expect(child.prompts()).toEqual(['first turn', 'next turn'])) + expect(child.spawns()).toBe(2) + expect(child.resumes()).toBe(1) +}) + +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) + for (const [key, value] of [ + ['model', 'scripted-alt-model'], + ['effort', 'high'] + ] as const) { + const fields = { key, value } + expect( + await host.setOption(caller, { + envelope: envelope('agentSession.setOption', fence, fields), + ...fields + }) + ).toMatchObject({ ok: true }) + } + const body = hostTestMessage('first message') + expect( + (await host.send(caller, { envelope: envelope('agentSession.send', fence, { body }), body })).ok + ).toBe(true) + child.releaseHandshake() + + 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' + }) +}) diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 02c7ae282f27..4ec651cf2fa1 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -90,9 +90,14 @@ async function send(text: string): Promise { } /** The chat's Stop; `turnId` names the turn it stops, as the phone does. */ -function stop(turnId?: string): Promise { +async function stop(turnId?: string): Promise { const fields = turnId === undefined ? {} : { turnId } - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', fields), + ...fields + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + return result } function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { 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 b57b6a3e1b52..0d0e012c8a33 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 @@ -158,6 +158,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. */ @@ -605,6 +606,7 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur expect(await held).toBe('held') expect(interrupts).toBe(1) turns.start() + await vi.advanceTimersByTimeAsync(1) await stopping expect(interrupts).toBe(2) @@ -632,15 +634,19 @@ describe('a Stop in that window that the turn never opens for', () => { return { stopping, sent } } - it('says Codex had no turn running when that turn ends first', async () => { - const { stopping } = await waitingStop() + it('ends the session even when that turn ends before the interrupt can take it', async () => { + const { stopping, sent } = await waitingStop() turns.end('interrupted') await stopping expect(interrupts).toBe(1) - expect(childCloses).toBe(0) - expect(await statusRows()).toContain('Codex had no turn running to stop.') + expect(childCloses).toBe(1) + // The end took the send back, so its own row says it never started and no Stop note repeats it. + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + ) + expect(await statusRows()).not.toContain('Cancellation requested.') }) it('lets a chat closed behind it close within its bound and one eviction', async () => { @@ -651,10 +657,7 @@ describe('a Stop in that window that the turn never opens for', () => { closing, CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS ) - await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) - expect(openWaits.ended).toEqual([]) - expect(childCloses).toBe(0) - await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) expect(openWaits.ended).toEqual(['turn-1']) expect(await closedWithinBound).not.toBe('held') @@ -673,10 +676,7 @@ describe('a Stop in that window that the turn never opens for', () => { stopStructuredAgentSessionRuntime(), CHILD_EVICTION_TIMEOUT_MS ) - await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) - expect(openWaits.ended).toEqual([]) - expect(childCloses).toBe(0) - await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) expect(openWaits.ended).toEqual(['turn-1']) expect(await quitWithinBound).not.toBe('held') expect(childCloses).toBe(1) @@ -878,7 +878,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await stopWhenItsTurnNeverOpens() - expect(childCloses).toBe(1) + expect(childCloses).toBe(2) expect(verdictOf((await settled()).submissions, next)).toBe('withdrawn') }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 49ca125b1d12..a5787efc69fd 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -519,10 +519,12 @@ 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') @@ -681,16 +683,18 @@ describe('a structured codex session over agentSession.*', () => { ).toBe(true) expect(itemsOf(missed).map(textOf).filter(Boolean)).toEqual(['Stopped.']) - // A runtime taking the session over is the other half of reconnect: the - // fence advances, the old child is reaped, and its replacement resumes the - // thread this session proved rather than forking a new one. + // Stop ended the session once its turn did. A runtime taking it over is the other half of + // reconnect: the fence advances, and the new child resumes the thread this session proved + // rather than forking a new one. const reaped = codex.live() + await vi.waitFor(() => expect(reaped.closed).toBe(true)) + const stoppedFence = + getStructuredAgentSessionHost()?.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? fence const resumed = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>( 'agentSession.ensure', - attachParams(fence) + attachParams(stoppedFence) ) - expect(resumed.fence).toBe(fence + 1) - expect(reaped.closed).toBe(true) + expect(resumed.fence).toBe(stoppedFence + 1) expect(codex.live().resumedThreadId).toBe(THREAD) expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ ok: true, 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 000000000000..19403cfa5aa8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -0,0 +1,188 @@ +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, + options: { unknown: 'unavailable-saved-option' }, + 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).toEqual({ + model: 'scripted/model-1', + effort: 'medium' + }) + ) + await stopStructuredAgentSessionRuntime() + expect(script.closes()).toBe(1) +}) + +it('keeps a model picked during startup when the optional state read predates applying the pick', async () => { + const { script, host, caller, fence } = await setup() + const fields = { key: 'model', value: 'scripted/model-2' } + const picked = await host.setOption(caller, { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: HOST_TEST_SESSION, + fields + }) + }, + ...fields + }) + expect(picked.ok).toBe(true) + const body = hostTestMessage('first message') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(sent.ok).toBe(true) + script.releaseHandshake() + // The pick reaches Pi before the message held for its start. + await vi.waitFor(() => expect(script.prompts()).toEqual(['first message'])) + const wire = script.wire() + expect(wire.indexOf('set_model')).toBeGreaterThan(-1) + expect(wire.indexOf('set_model')).toBeLessThan(wire.indexOf('prompt:first message')) + const { runtimeState, sessions, serialize } = host.collaboratorsForTests() + await vi.waitFor(() => expect(sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe('ready')) + await vi.waitFor(() => + expect(runtimeState.optionRevisions.current(HOST_TEST_SESSION)).toBeGreaterThan(1) + ) + await serialize(HOST_TEST_SESSION, async () => {}) + expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options?.model).toBe( + fields.value + ) +}) + +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 000000000000..66c7fd735e72 --- /dev/null +++ b/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts @@ -0,0 +1,323 @@ +// An operation only the agent's protocol session can perform (a rewind, a goal, the recovery of a +// rewind left in doubt) never reaches a child still proving its start, for any agent. Driven through +// the real Codex and ACP adapters with only the provider child 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 { ACP_LAUNCH_SPECS } from '../acp/acp-launch-specs' +import { acpScriptedChild } from '../acp/acp-scripted-child.test-fixture' +import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' +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('recovers a Codex rewind left in doubt once the restarted thread is open, then delivers the send that started it', async () => { + const child = codexScriptedChild({ + 'thread/read': () => ({ thread: { id: CODEX_SCRIPTED_THREAD, status: { type: 'idle' } } }), + // The rewound turn is gone and nothing earlier is retained: the provider applied the rewind. + '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 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: [] + } + })) + + const sent = send(host, 'after the rewind') + 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('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('sets a goal asked of a starting Codex chat on the thread once it 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) + child.releaseHandshake() + + expect(await goal).toMatchObject({ ok: true }) + expect(child.requests().filter(({ method }) => method.startsWith('thread/goal'))).toEqual([ + { + method: 'thread/goal/set', + params: { threadId: CODEX_SCRIPTED_THREAD, objective: 'Ship the release', status: 'active' } + } + ]) +}) + +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) +}) + +it('answers a goal asked of an ACP agent, which has none, without waiting out its start', async () => { + const spec = ACP_LAUNCH_SPECS[0]! + const child = acpScriptedChild(spec.agent)() + const host = await openChat(child, acpStructuredAgentDefinition(spec)) + 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(true) + expect(await goal).toMatchObject({ + ok: false, + refusal: { details: { reason: 'goalsUnsupported' } } + }) + child.releaseHandshake() + + expect(await send(host, 'after the goal')).toMatchObject({ ok: true }) + await eventually(() => expect(child.prompts()).toEqual(['after the goal'])) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 7566249677b6..231e51c015dc 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -11,7 +11,7 @@ // structured request with the refusal that says why. import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' -import type { PiRpcSessionDeps } from '../pi/rpc-session' +import type { StructuredAgentSessionTransportOverrides } from './structured-agent-session-transport-overrides' import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -25,8 +25,6 @@ import { import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' -import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' -import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps @@ -90,7 +88,7 @@ export function hasPersistedStructuredAgentSessionStore( } } -export type StructuredAgentSessionRuntimeDeps = { +export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportOverrides & { /** Host state root. The record store and the journal database both hang off it. */ stateDirectory: string /** Execution host this runtime *is*. A record pinned elsewhere is not ours to @@ -101,7 +99,6 @@ export type StructuredAgentSessionRuntimeDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveClaudeCommand?: () => string - openPiConnection?: PiRpcSessionDeps['openConnection'] /** Which version-gated flags a Claude CLI takes; absent never passes one. */ claudeCliFlags?: ClaudeCliFlagSupport /** Gives each chat a visuals folder and the skill that teaches it, and sweeps folders whose chat @@ -111,11 +108,6 @@ export type StructuredAgentSessionRuntimeDeps = { isEnabled: () => boolean workspaceVerdicts: NonNullable } - /** Provider transports are overridden only to drive the runtime against scripted children. */ - openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] - openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] - /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ - readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] /** Required, and asserted at install time — saved Arguments must never be silently omitted. */ resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise | string[] resolveLaunchEnv?: () => Promise @@ -322,6 +314,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), + ...(deps.startupLimits ? { startupLimits: deps.startupLimits } : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), ...(await modelCatalogHostDeps({ store, diff --git a/src/main/runtime/structured-agent-session-transport-overrides.ts b/src/main/runtime/structured-agent-session-transport-overrides.ts new file mode 100644 index 000000000000..d4f41c3ad688 --- /dev/null +++ b/src/main/runtime/structured-agent-session-transport-overrides.ts @@ -0,0 +1,24 @@ +import type { AcpStructuredSessionAdapterDeps } from '../acp/acp-structured-session-adapter-deps' +import type { AcpStructuredLaunchResolverDeps } 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 { 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'] + 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'] + /** The shipped startup limits unless a test shortens them. */ + startupLimits?: Partial +} diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 90d9b7f19a37..156a373ae17f 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -121,7 +121,8 @@ beforeEach(async () => { resolveClaudeAuthPolicy: () => ({ account: 'system' }), openClaudeConnection: claude.openConnection, readProcessStartTime: async () => HOST_TEST_NOW, - onLifecycleEvent: () => {} + // A rewind runs only once the agent has proven its start, which the host learns from these. + onLifecycleEvent: (event) => void host.handleAdapterEvent(event) }) log = recordingStructuredAgentSessionLogger() // Only Claude sessions are attached here; the router supplies the production create gate. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 05415627775d..f42c79016862 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -90,7 +90,8 @@ export function NativeChatStructuredSession( queueFollowUps, hostStopping: hostExecution.stopping, providerStarting: hostExecution.phase === 'starting', - providerRunning: hostExecution.phase !== null, + // A start's own verdict (a signed-out agent) exists only once that start is proven. + providerRunning: hostExecution.phase === 'ready', rewind: rewindHost, transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index f2af91bc5c3c..38b158682648 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -51,7 +51,7 @@ export function useStructuredAgentSession(args: { transportEnabled?: boolean /** The host has published the session but its provider has not answered startup yet. */ providerStarting?: boolean - /** The host runs the session's provider, started or not. */ + /** The host runs the session's provider and its start is proven. */ providerRunning?: boolean /** This view started the session; only then does the stored selection name what it runs. */ launch?: StructuredAgentSessionLaunchView diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 185c62c8333e..a3ad810e1c6c 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -388,7 +388,9 @@ export function isPersistedAgentSessionRecord( validated.providerContextBoundary.afterFence <= validated.lease.runtimeFence) && (validated.lease.claimStatus !== 'live' || (validated.lease.ownerProcess !== null && - head?.linkId === validated.lease.provenHandleLinkId && - head.mintedAtFence === validated.lease.runtimeFence)) + // Null: the owner's process is live and its provider has not answered with a handle yet. + (validated.lease.provenHandleLinkId === null || + (head?.linkId === validated.lease.provenHandleLinkId && + head.mintedAtFence === validated.lease.runtimeFence)))) ) } 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 d29a36c2da7d..001223f8f43f 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 @@ -15,8 +15,12 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionLifecycleEvent +} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +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 type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' @@ -41,6 +45,7 @@ import { } from './structured-agent-session-surface-execution' import { installableHost, + readyCodexProviderAdapter, structuredHostStub, turnItemSkew } from './structured-agent-session-host-fixture' @@ -76,7 +81,11 @@ import { describeReleasedStopNoteProjection } from './cross-version-stop-note-sc // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 -describeReleasedStopNoteProjection({ build: () => current, callBuild, runtimeStub }) +describeReleasedStopNoteProjection({ + build: () => current, + callBuild, + runtimeStub +}) const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update' @@ -150,7 +159,9 @@ describe('cross-version structured agent sessions', () => { expect(replies, `${method} must answer exactly once`).toHaveLength(1) expect(replies[0]).toMatchObject({ ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } + error: { + message: expect.stringContaining('structured_agent_session_unsupported') + } }) } for (const [name, spy] of Object.entries(hostCalls)) { @@ -213,7 +224,9 @@ describe('cross-version structured agent sessions', () => { expect(replies).toHaveLength(1) expect(replies[0]).toMatchObject({ ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } + error: { + message: expect.stringContaining('structured_agent_session_unsupported') + } }) if (baseline.methodNames.includes('agentSession.createSupport')) { // Older clients read the existing refusal fields and ignore additive error metadata. @@ -230,7 +243,10 @@ describe('cross-version structured agent sessions', () => { ) expect(replies).toHaveLength(1) // `supported` is all an older desktop or phone reads; the seed rides beside it. - expect(replies[0]).toMatchObject({ ok: true, result: { supported: true, seedOptions: SEED } }) + expect(replies[0]).toMatchObject({ + ok: true, + result: { supported: true, seedOptions: SEED } + }) }) }) @@ -243,7 +259,10 @@ describe('cross-version structured agent sessions', () => { for (const [clientCapabilities, item] of turnItemSkew.clients(baseline, current)) { const client = { clientKind: 'runtime' as const, clientCapabilities } const replies = await callBuild(current, 'agentSession.history', params, client) - expect(replies[0]).toMatchObject({ ok: true, result: { page: { items: [item] } } }) + expect(replies[0]).toMatchObject({ + ok: true, + result: { page: { items: [item] } } + }) } }) }) @@ -354,9 +373,18 @@ describe('cross-version structured agent sessions', () => { const fields = { itemId: 'item-1', expectedRevision: 1, - answers: [{ questionId: 'q1', optionIds: [], other: 'Wait for the capture. '.repeat(80) }] + answers: [ + { + questionId: 'q1', + optionIds: [], + other: 'Wait for the capture. '.repeat(80) + } + ] + } + const params = { + envelope: envelope({ method, fields, fence: 1 }), + ...fields } - const params = { envelope: envelope({ method, fields, fence: 1 }), ...fields } expect(current.capabilities).toContain(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY) for (const build of [current, baseline]) { const advertised = build.capabilities.includes( @@ -436,7 +464,9 @@ describe('cross-version structured agent sessions', () => { expect(replies).toHaveLength(1) expect(replies[0]).toMatchObject({ ok: true, - result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } + result: { + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } }) expect(updates).toEqual([[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]]) }) @@ -468,31 +498,18 @@ describe('cross-version structured agent sessions', () => { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) store = await openTestAgentSessionRecordStore(root) - const host = new StructuredAgentSessionHost({ + const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-vault' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ state: 'accepted' }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - setOption: async () => undefined - }, + adapter: readyCodexProviderAdapter({ + store, + sessionId: SESSION, + thread: THREAD, + now: NOW, + spawnToken: 'spawn-vault', + host: () => host + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-vault', @@ -607,8 +624,14 @@ describe('cross-version structured agent sessions', () => { await callBuild( current, 'session.tabs.createTerminal', - { worktree: `id:${WORKSPACE}`, command: `codex resume '${THREAD}'` }, - { clientKind: 'runtime', clientCapabilities: legacyClientCapabilities() }, + { + worktree: `id:${WORKSPACE}`, + command: `codex resume '${THREAD}'` + }, + { + clientKind: 'runtime', + clientCapabilities: legacyClientCapabilities() + }, runtime ) )[0] @@ -618,8 +641,15 @@ describe('cross-version structured agent sessions', () => { await callBuild( current, 'terminal.send', - { terminal: 'terminal-1', text: `codex resume '${THREAD}'`, enter: true }, - { clientKind: 'runtime', clientCapabilities: legacyClientCapabilities() }, + { + terminal: 'terminal-1', + text: `codex resume '${THREAD}'`, + enter: true + }, + { + clientKind: 'runtime', + clientCapabilities: legacyClientCapabilities() + }, runtime ) )[0] @@ -636,7 +666,10 @@ describe('cross-version structured agent sessions', () => { current, 'session.tabs.createTerminal', { worktree: `id:${WORKSPACE}`, command: 'echo unrelated' }, - { clientKind: 'runtime', clientCapabilities: legacyClientCapabilities() }, + { + clientKind: 'runtime', + clientCapabilities: legacyClientCapabilities() + }, runtime ) )[0] @@ -652,62 +685,80 @@ describe('cross-version structured agent sessions', () => { /** Holds a provider start open, so a reply's timing can be read against it. */ let startGate: Promise = Promise.resolve() + /** Holds a published child's handshake: it proves its start only once this settles. */ + let handshake: Promise = Promise.resolve() let starts = 0 - /** Phase 2 owns provider processes; the adapter is the only stub here. */ - function adapter(): StructuredAgentSessionAdapter { - return { - // Every real adapter answers this; without it adapterSupportsCreate falls through to - // `supportsLocation`, which this fake also lacks, so the client-supplied-location gate - // refused for the fake's silence rather than for the location. - supportsCreate: () => true, - acquire: async ({ fence }) => { - starts += 1 - await startGate - 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), - // A restarted host re-proves the thread it inherited; only the first - // owner of a session may claim to have created it. - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW + /** Phase 2 owns provider processes; the adapter is the only stub here. A ready provider + * proves its start once its handshake answers. */ + function adapter(host: () => StructuredAgentSessionHost): StructuredAgentSessionAdapter { + const proves = { + handleAdapterEvent: (event: StructuredAgentSessionLifecycleEvent) => + handshake.then(() => host().handleAdapterEvent(event)) + } + return startsWhenPublished( + { + // Every real adapter answers this; without it adapterSupportsCreate falls through to + // `supportsLocation`, which this fake also lacks, so the client-supplied-location gate + // refused for the fake's silence rather than for the location. + supportsCreate: () => true, + acquire: async ({ fence }) => { + starts += 1 + await startGate + 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), + // A restarted host re-proves the thread it inherited; only the first + // owner of a session may claim to have created it. + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? 'resumed' + : '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 }, - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } + () => proves + ) } /** 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): Promise { + async function bootHost(generation: string, startupLimits?: { silenceMs: number }) { store = await openTestAgentSessionRecordStore(root) - const host = new StructuredAgentSessionHost({ + const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: adapter(), + adapter: adapter(() => host), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation}`, // The provider died with the host that spawned it, which is what makes // the restarted host the legitimate next writer. probeOwner: async () => ({ outcome: 'pid-absent' }), - now: () => NOW + now: () => NOW, + ...(startupLimits ? { startupLimits } : {}) }) setStructuredAgentSessionHost(host) return host @@ -766,6 +817,7 @@ describe('cross-version structured agent sessions', () => { beforeEach(async () => { resetOperationIds() startGate = Promise.resolve() + handshake = Promise.resolve() root = await mkdtemp(join(tmpdir(), 'orca-cross-version-agent-session-')) runtime = runtimeStub() await bootHost('a') @@ -787,14 +839,20 @@ describe('cross-version structured agent sessions', () => { await restarted.restoreReadableSessions() // Restart restores the session for READING. The chat the client still has open takes its // hold, and that is what gives the session a provider child again. - await answer('agentSession.hold', { sessionId: SESSION, holderId: 'surface-1' }) + await answer('agentSession.hold', { + sessionId: SESSION, + holderId: 'surface-1' + }) const resumedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(resumedFence).toBeGreaterThan(created.fence) const second = await answer('agentSession.send', sendParams('after restart', resumedFence)) expect(second.ok).toBe(true) const events = ( - await call('agentSession.subscribe', { sessionId: SESSION, cursor: held }) + await call('agentSession.subscribe', { + sessionId: SESSION, + cursor: held + }) ).map((reply) => reply.result as AgentSessionSubscribeEvent) expect(events.map((event) => event.type)).toEqual(['batch']) const batch = events[0]?.type === 'batch' ? events[0].batch : null @@ -860,7 +918,11 @@ describe('cross-version structured agent sessions', () => { ]) expect(currentReply[0]).toMatchObject({ ok: true, - result: { value: { submission: { dispatchState: 'pending', handoverRecorded: true } } } + result: { + value: { + submission: { dispatchState: 'pending', handoverRecorded: true } + } + } }) open() await vi.waitFor(async () => @@ -871,5 +933,25 @@ describe('cross-version structured agent sessions', () => { ).toBe(true) ) }) + + // The create a released client sends answers at spawn now, so its first send's reply carries + // the handshake instead. A start that never proves itself is ended by the startup limit, and + // the reply answers with the message's rejection rather than hanging. + it("answers a released client's send once the startup limit ends a start that never proves itself", async () => { + const released = baseline.capabilities.filter( + (capability) => capability !== AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ) + const created = await answer('agentSession.create', createIntentParams()) + await bootHost('b', { silenceMs: 200 }) + handshake = new Promise(() => undefined) + + const params = sendParams('never started', created.fence) + const [reply] = await call('agentSession.send', params, released) + + expect(reply).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'rejected' } } } + }) + }) }) }) 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 d14aee3b5996..a7340834d773 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' @@ -50,15 +51,23 @@ export type ScenarioPort = { ) => Promise runtimeStub: () => unknown } -export const turnIdentity = { provider: 'orca', clientMessageId: 'turn-stop-test' } as const +export const turnIdentity = { + provider: 'orca', + clientMessageId: 'turn-stop-test' +} as const export const turnItemId = agentJournalItemKey(turnIdentity) -export const noteIdentity = { provider: 'orca', clientMessageId: 'stop:turn-stop-test' } as const +export const noteIdentity = { + provider: 'orca', + clientMessageId: 'stop:turn-stop-test' +} as const export const noteId = agentJournalItemKey(noteIdentity) export const scope = { kind: 'turn', turnItemId } as const export const ordinary = { kind: 'status', text: 'Cancellation requested.' } export const unconfirmed: AgentJournalItemBody = { kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) } export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { @@ -82,23 +91,32 @@ 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 }) => ({ + 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({ @@ -176,13 +194,24 @@ export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { settlementId: `end-${state}`, fence: 1, mutations: [ - { kind: 'item', identity: turnIdentity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { + kind: 'item', + identity: turnIdentity, + body, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ] }) - : journal.appendItem(turnIdentity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE })) + : journal.appendItem(turnIdentity, body, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) } async function note(body: AgentJournalItemBody = unconfirmed) { - await journal.appendItem(noteIdentity, body, { fence: 1, turnScope: scope }) + await journal.appendItem(noteIdentity, body, { + fence: 1, + turnScope: scope + }) } async function seed() { await turn('running') 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 cab588ad54de..9057f33c4859 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 @@ -1,4 +1,8 @@ import { vi } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import { startsWhenPublished } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support' +import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import { CODEX_STRUCTURED_AGENT } from '../../../src/main/codex/codex-structured-agent-definition' @@ -158,3 +162,40 @@ export const turnItemSkew = { ] as const } } + +/** A Codex provider that is ready as soon as it is published, as one with an instant handshake is, + * for a real host that only needs a running agent behind a chat. */ +export function readyCodexProviderAdapter(input: { + store: Pick + sessionId: string + thread: string + now: number + spawnToken: string + host: () => StructuredAgentSessionHost +}): StructuredAgentSessionAdapter { + const { store, now } = input + return startsWhenPublished( + { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: now, + spawnToken: store.getRecord(input.sessionId)?.lease.reservedSpawnToken ?? input.spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(input.thread), + origin: 'created', + mintedAtFence: fence, + observedAt: now + } + }), + dispatch: async () => ({ state: 'accepted' }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + input.host + ) +} 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 9fc2255514b2..91e5f6f7c1ac 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -27,6 +27,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-ty import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' +import { startsWhenPublished } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support' import { NO_STRUCTURED_AGENTS } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const WORKTREE = 'repo-1::/workspace/repo' @@ -42,24 +43,33 @@ function openHost(): void { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: 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' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - closeSession, - releaseAcquisition: async () => true, - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - }, + // A ready provider proves its start as soon as it is published. + adapter: startsWhenPublished( + { + acquire: 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' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + closeSession, + releaseAcquisition: async () => true, + dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + () => host + ), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', @@ -97,7 +107,10 @@ function serveDesktopRuntime(): void { try { return { ok: true, result: host.handoffStatus(params.sessionId ?? '') } } catch (error) { - return { ok: false, error: { code: String(error), message: String(error) } } + return { + ok: false, + error: { code: String(error), message: String(error) } + } } } throw new Error(`Unexpected runtime method: ${method}`) From ad5d2ccce6852b03696cadd3dcf8c78fec3e91b8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:00:17 -0700 Subject: [PATCH 02/10] Keep an agent's own sign-in words on a refused start; let a picker read wait out an agent's start A signed-out Grok, OpenCode or OMP that wrote to stderr before refusing its start showed that log line instead of its sign-in message. A sign-in refusal now keeps the agent's own words; other start failures keep the stderr tail. While a chat's agent proves its start, the model-catalog read answers listingInProgress and a waiting read waits the start out (bounded), so a client that re-reads only then still hears Pi's signed-out verdict. --- src/main/acp/acp-structured-child.ts | 22 +++---- src/main/acp/acp-structured-sign-in.test.ts | 19 ++++++ src/main/acp/acp-turn-failures.ts | 18 ++---- .../structured-agent-session-host.ts | 6 ++ ...tured-agent-session-provider-start-hold.ts | 2 +- src/main/pi/pi-scripted-child.test-fixture.ts | 6 +- ...uctured-agent-session-options-read.test.ts | 64 +++++++++++++++++++ .../structured-agent-session-options-read.ts | 27 ++++++-- ...tructured-agent-session-pi-startup.test.ts | 21 +++++- src/shared/agent-session-wire.ts | 7 +- 10 files changed, 152 insertions(+), 40 deletions(-) diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index f85593aed80c..66c1414bc1fa 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -5,7 +5,7 @@ import { } from '../../shared/agent-session-failure' import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { AcpAgentError } from './acp-errors' -import { acpAuthenticationRequired, acpPromptErrorDetail } from './acp-turn-failures' +import { acpAuthenticationRequired, acpSignInRequiredFailure } from './acp-turn-failures' import type { AcpStructuredSession } from './acp-structured-session' export type AcpStartingSession = Pick< @@ -33,20 +33,14 @@ export function acpStartupFailure( session: AcpStartingSession, error: unknown ): SubmissionRejectionFact { - const { dialect } = session.spec - const signIn = acpAuthenticationRequired(dialect, error) + 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) + } const text = - session.connection.stderrTail() || - (error instanceof AcpAgentError - ? signIn - ? acpPromptErrorDetail(dialect, error) - : error.message - : '') + session.connection.stderrTail() || (error instanceof AcpAgentError ? error.message : '') const detail = providerDiagnostic(text, 'person') - return agentSessionFailureFact( - signIn ? 'notSignedIn' : 'providerStartFailed', - detail ? { detail } : {} - ) + return agentSessionFailureFact('providerStartFailed', detail ? { detail } : {}) } export function endAcpStartingSession( @@ -71,7 +65,7 @@ export function endAcpStartingSession( (session.connection.stderrTail() || `${session.spec.agent} ACP agent ended while starting`), failure: requested ? agentSessionFailureFact('hostStopped') - : detail + : detail && failure.kind !== 'notSignedIn' ? { ...failure, detail } : failure, cause: requested ? 'requested-close' : 'unexpected-exit', diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index a1990034e2b5..9e02e4f7aac3 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -96,6 +96,25 @@ it.each(ACP_LAUNCH_SPECS)( } ) +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.ended).toMatchObject([ + { failure: { kind: 'notSignedIn', detail: { 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-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index ba87ec6a5050..ee6a4f92d1d1 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 } 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'] @@ -41,18 +40,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/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 0d0f138aac6e..573cea4ed1cf 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 @@ -48,6 +48,7 @@ import { import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import * as conversation from './structured-agent-session-host-delivery' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { awaitProviderChildStart } from './structured-agent-session-provider-start-hold' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -305,6 +306,11 @@ export class StructuredAgentSessionHost { setOption = this.mutations.setOption changeThreadGoal = this.mutations.changeThreadGoal readOptions = this.mutations.readOptions + /** The chat's agent has not proven its start yet; `awaitProviderStart` waits it out, bounded. */ + providerStarting = (sessionId: string) => + this.sessions.get(sessionId)?.child?.phase === 'starting' + awaitProviderStart = (sessionId: string, ceilingMs: number) => + awaitProviderChildStart(this.sessions.get(sessionId), ceilingMs) rewind = (caller: StructuredAgentSessionCaller, params: AgentSessionRewindParams) => rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts index 425d4e64766d..ef7297c1ac98 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts @@ -35,7 +35,7 @@ export function refuseWhileProviderStarting( } /** The startup limit always ends a start; the ceiling only bounds a wait nothing else would. */ -async function awaitProviderChildStart( +export async function awaitProviderChildStart( session: Pick | undefined, ceilingMs: number = STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS ): Promise { diff --git a/src/main/pi/pi-scripted-child.test-fixture.ts b/src/main/pi/pi-scripted-child.test-fixture.ts index 0f069f257c09..9130ea6f3a81 100644 --- a/src/main/pi/pi-scripted-child.test-fixture.ts +++ b/src/main/pi/pi-scripted-child.test-fixture.ts @@ -24,7 +24,7 @@ class ScriptedPiConnection implements PiRpcConnection { constructor( launch: ProviderProcessLaunch, private readonly handlers: JsonlRpcAgentConnectionOptions, - private readonly script: ScriptedAgentChild, + private readonly script: ScriptedPiChild, private readonly received: string[], private readonly wire: string[], private readonly onClosed: () => void, @@ -55,7 +55,7 @@ class ScriptedPiConnection implements PiRpcConnection { return this.state() } if (command === 'get_available_models') { - return { models: [this.model] } + return { models: this.script.listsNoModels ? [] : [this.model] } } if (command === 'get_commands') { return { commands: [] } @@ -182,6 +182,8 @@ class ScriptedPiConnection implements PiRpcConnection { } export type ScriptedPiChild = ScriptedAgentChild & { + /** Lists no model, as a signed-out Pi does. */ + listsNoModels?: boolean /** Every request command and prompt any spawn received, in order. */ wire(): readonly string[] } 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 56db442ce2c2..7f168808bbd5 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 @@ -191,6 +191,7 @@ describe("agentSession.modelCatalog with what the running agent's start said", ( startUnavailable.mockClear() setStructuredAgentSessionHost( Object.assign(hostStub(), { + providerStarting: () => false, deps: { modelCatalog: { read }, store: { getRecord: () => record }, @@ -231,3 +232,66 @@ describe("agentSession.modelCatalog with what the running agent's start said", ( expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) }) }) + +describe("agentSession.modelCatalog while the chat's agent proves its start", () => { + const record: AgentSessionRecord = { ...agentSessionRecordFixture(), provider: 'pi' } + let unavailable: AgentSessionUnavailable | undefined + let starting = true + const start = Promise.withResolvers<'ready'>() + const awaitProviderStart = vi.fn(() => start.promise) + const readFor = (extra: { waitForListing?: true; savedOnly?: true } = {}) => + call( + 'agentSession.modelCatalog', + { agent: 'pi', sessionId: record.sessionId, ...extra }, + PI_CLIENT + ) + + beforeEach(() => { + unavailable = undefined + starting = true + awaitProviderStart.mockClear() + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + providerStarting: () => starting, + awaitProviderStart, + deps: { + modelCatalog: { read: vi.fn(async () => ({ origin: 'unknown' as const })) }, + store: { getRecord: () => record }, + adapter: { startUnavailable: () => unavailable } + } + }) + ) + }) + + // A client that re-reads only on this flag still learns what the start says (a signed-out Pi). + it('says the answer is in progress, and a waiting read answers with what the start found', async () => { + const first = await readFor() + expect(first).toMatchObject({ + ok: true, + result: { origin: 'unknown', listingInProgress: true } + }) + expect(first).not.toMatchObject({ result: { unavailable: expect.anything() } }) + expect(awaitProviderStart).not.toHaveBeenCalled() + + let settled = false + const waiting = readFor({ waitForListing: true }).finally(() => { + settled = true + }) + await vi.waitFor(() => + expect(awaitProviderStart).toHaveBeenCalledWith(record.sessionId, 30_000) + ) + expect(settled).toBe(false) + starting = false + unavailable = { reason: 'notSignedIn' } + start.resolve('ready') + const answered = await waiting + expect(answered).toMatchObject({ ok: true, result: { unavailable: { reason: 'notSignedIn' } } }) + expect(answered).not.toMatchObject({ result: { listingInProgress: true } }) + }) + + it('never holds a saved-only read', async () => { + const reply = await readFor({ savedOnly: true }) + expect(reply).not.toMatchObject({ result: { listingInProgress: true } }) + expect(awaitProviderStart).not.toHaveBeenCalled() + }) +}) 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 2b0683d08a36..27a76ef05da4 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 @@ -24,6 +24,9 @@ import { agentSessionRefusalError } from '../../../../shared/agent-session-wire- import { clientReadsOptionsWithoutModel } from './structured-agent-session-policy' import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' +// Under every client's waiting-read timeout (the phone's is 45 s); the catalog's wait runs beside it. +const START_WAIT_MS = 30_000 + export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ name: 'agentSession.handoffStatus', @@ -87,13 +90,25 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ .catch(() => null) return catalog.read({ ...params, workspacePath }) } - const answer = await read() + const ownAgent = record?.provider === params.agent ? record : null + // A start under way can still change the answer, and a client re-reads only on + // `listingInProgress`: the answer says so, and a waiting read waits the start out. + const starting = + ownAgent !== null && !params.savedOnly && host.providerStarting(ownAgent.sessionId) + const [answer] = await Promise.all([ + read(), + starting && params.waitForListing + ? host.awaitProviderStart(ownAgent.sessionId, START_WAIT_MS) + : null + ]) // The chat's running agent can say what no listing does: a Pi that started with no model. - const started = - record?.provider === params.agent - ? host.deps.adapter.startUnavailable?.(record.sessionId) - : undefined - return started && !answer.unavailable ? { ...answer, unavailable: started } : answer + const started = ownAgent + ? host.deps.adapter.startUnavailable?.(ownAgent.sessionId) + : undefined + const result = started && !answer.unavailable ? { ...answer, unavailable: started } : answer + return starting && !params.waitForListing + ? { ...result, listingInProgress: true as const } + : result } }) ] diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index 19403cfa5aa8..d3b51c1f38ef 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -28,10 +28,9 @@ afterEach(async () => { } }) -async function setup() { +async function setup(script = piScriptedChild()) { directory = await mkdtemp(join(tmpdir(), 'orca-pi-startup-')) const root = directory - const script = piScriptedChild() const host = await ensureStructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), stateDirectory: root, @@ -186,3 +185,21 @@ it('ends Pi on Stop and resumes its real session file when the next send starts expect(script.prompts()).toEqual(['start a long turn', 'resume after Stop']) ) }) + +// The picker's catalog read waits this out, so a client that reads once at spawn still hears it. +it('lets a read wait out Pi proving its start, then reads that start said Pi is signed out', async () => { + const { script, host } = await setup(Object.assign(piScriptedChild(), { listsNoModels: true })) + expect(host.providerStarting(HOST_TEST_SESSION)).toBe(true) + let settled = false + const waited = host.awaitProviderStart(HOST_TEST_SESSION, 30_000).finally(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(settled).toBe(false) + script.releaseHandshake() + expect(await waited).toBe('ready') + expect(host.providerStarting(HOST_TEST_SESSION)).toBe(false) + expect(host.deps.adapter.startUnavailable?.(HOST_TEST_SESSION)).toEqual({ + reason: 'notSignedIn' + }) +}) diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index ae2ddc18e25b..f6bbce86c2d0 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -414,9 +414,10 @@ export type AgentSessionFastModeSupport = { * surface: an older host simply lacks the method. */ export type AgentSessionModelCatalogResult = { - /** The host is running the listing this answer is waiting on (its first for the account, or - * the probe re-checking `unavailable`); a `waitForListing` read answers when it lands. Absent - * from a host that predates it; such a host sends it only with `unknown`. */ + /** The host is running the listing this answer is waiting on (its first for the account, the + * probe re-checking `unavailable`, or the chat's agent proving its start); a `waitForListing` + * read answers when it lands. Absent from a host that predates it; such a host sends it only + * with `unknown`. */ listingInProgress?: true /** Why no chat can start under the account, as the host's probe last found it. Absent is * unknown, which shows nothing; an older host never sends it. */ From a6c3a0fada321cce276ed6d75f3717bbe3162fce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:24:01 -0700 Subject: [PATCH 03/10] Take the model-catalog start hold back out The gap it closed exists only for a desktop app built between #26743 and this change; no released app shows the Pi notice yet, and the first send still says not signed in. Not worth a held read on every agent's start. --- .../structured-agent-session-host.ts | 6 -- ...tured-agent-session-provider-start-hold.ts | 2 +- src/main/pi/pi-scripted-child.test-fixture.ts | 6 +- ...uctured-agent-session-options-read.test.ts | 64 ------------------- .../structured-agent-session-options-read.ts | 27 ++------ ...tructured-agent-session-pi-startup.test.ts | 21 +----- src/shared/agent-session-wire.ts | 7 +- 7 files changed, 14 insertions(+), 119 deletions(-) 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 573cea4ed1cf..0d0f138aac6e 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 @@ -48,7 +48,6 @@ import { import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import * as conversation from './structured-agent-session-host-delivery' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { awaitProviderChildStart } from './structured-agent-session-provider-start-hold' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -306,11 +305,6 @@ export class StructuredAgentSessionHost { setOption = this.mutations.setOption changeThreadGoal = this.mutations.changeThreadGoal readOptions = this.mutations.readOptions - /** The chat's agent has not proven its start yet; `awaitProviderStart` waits it out, bounded. */ - providerStarting = (sessionId: string) => - this.sessions.get(sessionId)?.child?.phase === 'starting' - awaitProviderStart = (sessionId: string, ceilingMs: number) => - awaitProviderChildStart(this.sessions.get(sessionId), ceilingMs) rewind = (caller: StructuredAgentSessionCaller, params: AgentSessionRewindParams) => rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts index ef7297c1ac98..425d4e64766d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts @@ -35,7 +35,7 @@ export function refuseWhileProviderStarting( } /** The startup limit always ends a start; the ceiling only bounds a wait nothing else would. */ -export async function awaitProviderChildStart( +async function awaitProviderChildStart( session: Pick | undefined, ceilingMs: number = STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS ): Promise { diff --git a/src/main/pi/pi-scripted-child.test-fixture.ts b/src/main/pi/pi-scripted-child.test-fixture.ts index 9130ea6f3a81..0f069f257c09 100644 --- a/src/main/pi/pi-scripted-child.test-fixture.ts +++ b/src/main/pi/pi-scripted-child.test-fixture.ts @@ -24,7 +24,7 @@ class ScriptedPiConnection implements PiRpcConnection { constructor( launch: ProviderProcessLaunch, private readonly handlers: JsonlRpcAgentConnectionOptions, - private readonly script: ScriptedPiChild, + private readonly script: ScriptedAgentChild, private readonly received: string[], private readonly wire: string[], private readonly onClosed: () => void, @@ -55,7 +55,7 @@ class ScriptedPiConnection implements PiRpcConnection { return this.state() } if (command === 'get_available_models') { - return { models: this.script.listsNoModels ? [] : [this.model] } + return { models: [this.model] } } if (command === 'get_commands') { return { commands: [] } @@ -182,8 +182,6 @@ class ScriptedPiConnection implements PiRpcConnection { } export type ScriptedPiChild = ScriptedAgentChild & { - /** Lists no model, as a signed-out Pi does. */ - listsNoModels?: boolean /** Every request command and prompt any spawn received, in order. */ wire(): readonly string[] } 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 7f168808bbd5..56db442ce2c2 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 @@ -191,7 +191,6 @@ describe("agentSession.modelCatalog with what the running agent's start said", ( startUnavailable.mockClear() setStructuredAgentSessionHost( Object.assign(hostStub(), { - providerStarting: () => false, deps: { modelCatalog: { read }, store: { getRecord: () => record }, @@ -232,66 +231,3 @@ describe("agentSession.modelCatalog with what the running agent's start said", ( expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) }) }) - -describe("agentSession.modelCatalog while the chat's agent proves its start", () => { - const record: AgentSessionRecord = { ...agentSessionRecordFixture(), provider: 'pi' } - let unavailable: AgentSessionUnavailable | undefined - let starting = true - const start = Promise.withResolvers<'ready'>() - const awaitProviderStart = vi.fn(() => start.promise) - const readFor = (extra: { waitForListing?: true; savedOnly?: true } = {}) => - call( - 'agentSession.modelCatalog', - { agent: 'pi', sessionId: record.sessionId, ...extra }, - PI_CLIENT - ) - - beforeEach(() => { - unavailable = undefined - starting = true - awaitProviderStart.mockClear() - setStructuredAgentSessionHost( - Object.assign(hostStub(), { - providerStarting: () => starting, - awaitProviderStart, - deps: { - modelCatalog: { read: vi.fn(async () => ({ origin: 'unknown' as const })) }, - store: { getRecord: () => record }, - adapter: { startUnavailable: () => unavailable } - } - }) - ) - }) - - // A client that re-reads only on this flag still learns what the start says (a signed-out Pi). - it('says the answer is in progress, and a waiting read answers with what the start found', async () => { - const first = await readFor() - expect(first).toMatchObject({ - ok: true, - result: { origin: 'unknown', listingInProgress: true } - }) - expect(first).not.toMatchObject({ result: { unavailable: expect.anything() } }) - expect(awaitProviderStart).not.toHaveBeenCalled() - - let settled = false - const waiting = readFor({ waitForListing: true }).finally(() => { - settled = true - }) - await vi.waitFor(() => - expect(awaitProviderStart).toHaveBeenCalledWith(record.sessionId, 30_000) - ) - expect(settled).toBe(false) - starting = false - unavailable = { reason: 'notSignedIn' } - start.resolve('ready') - const answered = await waiting - expect(answered).toMatchObject({ ok: true, result: { unavailable: { reason: 'notSignedIn' } } }) - expect(answered).not.toMatchObject({ result: { listingInProgress: true } }) - }) - - it('never holds a saved-only read', async () => { - const reply = await readFor({ savedOnly: true }) - expect(reply).not.toMatchObject({ result: { listingInProgress: true } }) - expect(awaitProviderStart).not.toHaveBeenCalled() - }) -}) 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 27a76ef05da4..2b0683d08a36 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 @@ -24,9 +24,6 @@ import { agentSessionRefusalError } from '../../../../shared/agent-session-wire- import { clientReadsOptionsWithoutModel } from './structured-agent-session-policy' import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' -// Under every client's waiting-read timeout (the phone's is 45 s); the catalog's wait runs beside it. -const START_WAIT_MS = 30_000 - export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ name: 'agentSession.handoffStatus', @@ -90,25 +87,13 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ .catch(() => null) return catalog.read({ ...params, workspacePath }) } - const ownAgent = record?.provider === params.agent ? record : null - // A start under way can still change the answer, and a client re-reads only on - // `listingInProgress`: the answer says so, and a waiting read waits the start out. - const starting = - ownAgent !== null && !params.savedOnly && host.providerStarting(ownAgent.sessionId) - const [answer] = await Promise.all([ - read(), - starting && params.waitForListing - ? host.awaitProviderStart(ownAgent.sessionId, START_WAIT_MS) - : null - ]) + const answer = await read() // The chat's running agent can say what no listing does: a Pi that started with no model. - const started = ownAgent - ? host.deps.adapter.startUnavailable?.(ownAgent.sessionId) - : undefined - const result = started && !answer.unavailable ? { ...answer, unavailable: started } : answer - return starting && !params.waitForListing - ? { ...result, listingInProgress: true as const } - : result + const started = + record?.provider === params.agent + ? host.deps.adapter.startUnavailable?.(record.sessionId) + : undefined + return started && !answer.unavailable ? { ...answer, unavailable: started } : answer } }) ] diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index d3b51c1f38ef..19403cfa5aa8 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -28,9 +28,10 @@ afterEach(async () => { } }) -async function setup(script = piScriptedChild()) { +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, @@ -185,21 +186,3 @@ it('ends Pi on Stop and resumes its real session file when the next send starts expect(script.prompts()).toEqual(['start a long turn', 'resume after Stop']) ) }) - -// The picker's catalog read waits this out, so a client that reads once at spawn still hears it. -it('lets a read wait out Pi proving its start, then reads that start said Pi is signed out', async () => { - const { script, host } = await setup(Object.assign(piScriptedChild(), { listsNoModels: true })) - expect(host.providerStarting(HOST_TEST_SESSION)).toBe(true) - let settled = false - const waited = host.awaitProviderStart(HOST_TEST_SESSION, 30_000).finally(() => { - settled = true - }) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(settled).toBe(false) - script.releaseHandshake() - expect(await waited).toBe('ready') - expect(host.providerStarting(HOST_TEST_SESSION)).toBe(false) - expect(host.deps.adapter.startUnavailable?.(HOST_TEST_SESSION)).toEqual({ - reason: 'notSignedIn' - }) -}) diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index f6bbce86c2d0..ae2ddc18e25b 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -414,10 +414,9 @@ export type AgentSessionFastModeSupport = { * surface: an older host simply lacks the method. */ export type AgentSessionModelCatalogResult = { - /** The host is running the listing this answer is waiting on (its first for the account, the - * probe re-checking `unavailable`, or the chat's agent proving its start); a `waitForListing` - * read answers when it lands. Absent from a host that predates it; such a host sends it only - * with `unknown`. */ + /** The host is running the listing this answer is waiting on (its first for the account, or + * the probe re-checking `unavailable`); a `waitForListing` read answers when it lands. Absent + * from a host that predates it; such a host sends it only with `unknown`. */ listingInProgress?: true /** Why no chat can start under the account, as the host's probe last found it. Absent is * unknown, which shows nothing; an older host never sends it. */ From 2a7fa4e6004bd94db9ba1cdaf1df756aa44ad53b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:28:38 -0700 Subject: [PATCH 04/10] fix(native-chat): a Codex or Pi start has only the host's start deadline Codex sent its startup `initialize` with a hard 15 s timeout, and `thread/start` or `thread/resume` with the 30 s request timeout, under the host's startup attempt (a minute without output, ten minutes at most). A Codex that answered after 15 s was closed and the held message reported as not sent. Pi's `get_state` and saved model and effort restores had the same 30 s cap. A request the start cannot go on without now waits as long as the host's attempt does, as Claude's initialize and ACP's protocol requests already did. Optional reads that never fail a start keep their own bounds. Other callers of the Codex handshake keep 15 s. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + src/main/codex/codex-app-server-handshake.ts | 7 +- .../codex-scripted-child.test-fixture.ts | 25 +++- .../codex/codex-structured-session-start.ts | 14 ++- src/main/pi/rpc-options.test.ts | 4 +- src/main/pi/rpc-options.ts | 7 +- src/main/pi/rpc-session-startup.ts | 11 +- ...agent-session-codex-start-deadline.test.ts | 117 ++++++++++++++++++ 8 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-codex-start-deadline.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index e84ab452f89e..c3ed769cc7dc 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -237,6 +237,7 @@ 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/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-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/src/main/codex/codex-app-server-handshake.ts b/src/main/codex/codex-app-server-handshake.ts index 9c89d653baa6..27e2ce6c71bc 100644 --- a/src/main/codex/codex-app-server-handshake.ts +++ b/src/main/codex/codex-app-server-handshake.ts @@ -2,8 +2,11 @@ import type { CodexAppServerConnection } from './codex-app-server-connection-typ const HANDSHAKE_TIMEOUT_MS = 15_000 +/** `timeoutMs`: a native chat's start passes the host's start ceiling, whose attempt owns when a + * start gives up; other callers keep the handshake's own bound. */ export async function initializeCodexAppServerConnection( - connection: CodexAppServerConnection + connection: CodexAppServerConnection, + timeoutMs = HANDSHAKE_TIMEOUT_MS ): Promise { await connection.request( 'initialize', @@ -16,7 +19,7 @@ export async function initializeCodexAppServerConnection( extensions: {} } }, - { timeoutMs: HANDSHAKE_TIMEOUT_MS } + { timeoutMs } ) connection.notify('initialized') } diff --git a/src/main/codex/codex-scripted-child.test-fixture.ts b/src/main/codex/codex-scripted-child.test-fixture.ts index 526270ff1b7d..c8f4fd5d8a21 100644 --- a/src/main/codex/codex-scripted-child.test-fixture.ts +++ b/src/main/codex/codex-scripted-child.test-fixture.ts @@ -4,6 +4,7 @@ import type { 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' @@ -16,6 +17,26 @@ export type ScriptedCodexChild = ScriptedAgentChild & { 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> = {} @@ -43,13 +64,13 @@ export const codexScriptedChild = ( get closed() { return ended }, - request: async (method, params) => { + request: async (method, params, options) => { if (ended) { throw new Error('scripted Codex child closed') } requests.push({ method, ...(params ? { params } : {}) }) if (method === 'initialize') { - return holdHandshakes ? gate.promise : {} + return holdHandshakes ? heldAnswer(gate.promise, method, options?.timeoutMs) : {} } const route = routes[method] if (method === 'thread/resume') { diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index f195cfb82b29..a4a3085cb928 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -28,6 +28,7 @@ import { type CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' import type { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' +import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' type CodexStartInput = { input: StructuredAgentSessionAcquireInput @@ -62,7 +63,12 @@ export async function startCodexStructuredSession(start: CodexStartInput): Promi } } try { - await initializeCodexAppServerConnection(connection) + // The host's startup attempt is this start's only deadline (60 s silent, 10 min at most); a + // request the start cannot go on without waits as long as that attempt does. + await initializeCodexAppServerConnection( + connection, + STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS + ) child.initializeAnswered = true current() const threadLaunch = await withCodexVisualsThreadConfig(connection, launch, { @@ -71,7 +77,11 @@ export async function startCodexStructuredSession(start: CodexStartInput): Promi }) current() const optionRevision = input.optionRevision?.() ?? 0 - const opened = await openCodexThread(connection, threadLaunch, deps.requestTimeoutMs) + const opened = await openCodexThread( + connection, + threadLaunch, + STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS + ) current() start.setPrimaryThreadId(opened.threadId) const restored = translator?.restoreThread(opened.threadId, opened.thread ?? {}) diff --git a/src/main/pi/rpc-options.test.ts b/src/main/pi/rpc-options.test.ts index 5beaefb54ca1..d15c602cf550 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 4f75e064c6e1..b881d31f1729 100644 --- a/src/main/pi/rpc-options.ts +++ b/src/main/pi/rpc-options.ts @@ -92,17 +92,18 @@ export async function applyPiRpcSessionOption( rpc: PiRpcRequester, selected: Map, key: string, - value: string + value: string, + options: { timeoutMs?: number | null } = {} ): Promise>> { if (key === 'model') { const model = parsePiModelOptionId(value) modelSchema.parse({ provider: model.provider, id: model.modelId }) - await rpc.request('set_model', model) + await rpc.request('set_model', model, options) } 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 }) + await rpc.request('set_thinking_level', { level: value }, options) } else { throw new Error(`Unsupported Pi option: ${key}`) } diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index 68f1431fd64d..308f4bcb0ad2 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -20,6 +20,11 @@ import { } from './rpc-options' import { piRpcStateSchema, type PiRpcState } from './rpc-protocol' import type { PiRpcConnection, PiRpcSessionDeps } from './rpc-session' +import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' + +/** What the start cannot go on without waits as long as the host's startup attempt does, its only + * deadline; the optional reads keep their own bound and never fail the start. */ +const START_REQUEST = { timeoutMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } export class PiRpcSessionStartup { started = false @@ -46,7 +51,7 @@ export class PiRpcSessionStartup { async run(launch: PiRpcResolvedLaunch): Promise { const optionRevision = this.input.optionRevision?.() ?? 0 - const answer = await this.wait(this.connection.request('get_state')) + const answer = await this.wait(this.connection.request('get_state', {}, START_REQUEST)) this.answered = true this.assertLive() const state = piRpcStateSchema.parse(answer) @@ -74,7 +79,9 @@ export class PiRpcSessionStartup { picked.add(key) try { this.assertLive() - await this.wait(applyPiRpcSessionOption(this.connection, this.selected, key, value)) + await this.wait( + applyPiRpcSessionOption(this.connection, this.selected, key, value, START_REQUEST) + ) current[key] = value } catch (error) { if (!(error instanceof JsonlRpcResponseError)) { 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 000000000000..bc5842fa0e0c --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-start-deadline.test.ts @@ -0,0 +1,117 @@ +// A Codex start has one deadline, the host's startup attempt (60 s without a word, 10 min at most): +// its own request bounds no longer end a slow `initialize` first. + +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 { STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' +import { recordAgentSessionStartup } from '../observability/agent-session-instrumentation' +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 after 20 s', async () => { + const child = codexScriptedChild() + await chatHoldingAMessage(child) + await vi.advanceTimersByTimeAsync(20_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('ends a Codex that never answers at the host silence limit, not at 15 s', async () => { + const child = codexScriptedChild() + await chatHoldingAMessage(child) + await vi.advanceTimersByTimeAsync(STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS - 1_000) + expect(child.closes()).toBe(0) + expect(startupOutcomes()).toEqual([]) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.waitFor(() => expect(child.closes()).toBe(1)) + expect(startupOutcomes()).toEqual(['silent']) + expect(child.prompts()).toEqual([]) +}) From 5c66c6998bbdd4e74b456a6a0e34e5f8771d6f9b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:32:43 -0700 Subject: [PATCH 05/10] fix(native-chat): settle a signed-out Pi's first message once; keep held picks shown while the agent starts A child started for a send is no longer replaced as signed out for that same send, which restarted a signed-out Pi forever. The provisional launch latches its held picks so the disabled pickers show the saved pick during the start. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + .../structured-agent-session-host-delivery.ts | 2 +- ...red-agent-session-signed-out-child.test.ts | 22 ++++- ...ructured-agent-session-signed-out-child.ts | 10 +- src/main/pi/pi-scripted-child.test-fixture.ts | 22 ++++- ...-agent-session-pi-signed-out-start.test.ts | 94 +++++++++++++++++++ ...se-native-chat-provisional-launch.test.tsx | 34 ++++++- .../use-native-chat-provisional-launch.ts | 29 +++++- 8 files changed, 199 insertions(+), 15 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 079462313353..12c1f21339a6 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -244,6 +244,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-integration.test.ts', 'src/main/runtime/structured-agent-session-mobile-restore.test.ts', 'src/main/runtime/structured-agent-session-paired-caller.test.ts', + 'src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts', 'src/main/runtime/structured-agent-session-pi-startup.test.ts', 'src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts', 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 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 0ed0cb3a5472..bd9102421ed3 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 @@ -76,7 +76,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { const session = input.clientDelivery.readStopping(sessionId) ? undefined : sessions.get(sessionId) - await retireSignedOutStructuredAgentSessionChild(sessionId, session, { + await retireSignedOutStructuredAgentSessionChild(sessionId, session, startedFor, { work: { childWork: () => input.clientDelivery.readChildWork(sessionId), hasOpenDispatch: () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts index ae09e13d8782..13fa794a4eb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts @@ -205,10 +205,11 @@ describe('retireSignedOutStructuredAgentSessionChild', () => { const retire = async ( input: Parameters[0], work: Partial = {}, - stopAgent = vi.fn(async () => undefined) + stopAgent = vi.fn(async () => undefined), + startedFor = 'next-send' ) => { const warn = vi.fn() - await retireSignedOutStructuredAgentSessionChild('session', conversation(input), { + await retireSignedOutStructuredAgentSessionChild('session', conversation(input), startedFor, { work: { ...idle, ...work }, stopAgent, logger: { warn, error: vi.fn() } @@ -220,6 +221,23 @@ describe('retireSignedOutStructuredAgentSessionChild', () => { expect((await retire(signedOut)).stopAgent).toHaveBeenCalledWith('session') }) + it('keeps a child started for the send it is about to take', async () => { + const own = await retire( + { ...signedOut, child: childAt({ startedFor: 'next-send' }) }, + {}, + undefined, + 'next-send' + ) + expect(own.stopAgent).not.toHaveBeenCalled() + const earlier = await retire( + { ...signedOut, child: childAt({ startedFor: 'earlier-send' }) }, + {}, + undefined, + 'next-send' + ) + expect(earlier.stopAgent).toHaveBeenCalledWith('session') + }) + it('keeps it while it owes work the idle sweep also protects', async () => { // A pending prompt is the shared check's too; the idle sweep's tests cover that case. for (const kept of [ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts index 40ea59a69eb8..c9c891bc3791 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts @@ -78,12 +78,15 @@ function owesWakeUp( ) } -/** For a caller inside the session's serialize, before it hands the next send to the child. Work - * the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails leaves - * the close begun, which the start joins. */ +/** For a caller inside the session's serialize, before it hands the next send (`startedFor`) to + * the child. A child started for that same send keeps it: the send has not reached any child yet, + * and the agent's own refusal settles it once, where a new child would refuse it again forever. + * Work the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails + * leaves the close begun, which the start joins. */ export async function retireSignedOutStructuredAgentSessionChild( sessionId: string, session: SignedOutReading | undefined, + startedFor: string, deps: { work: StructuredAgentSessionChildWorkReads startUnavailable?: () => AgentSessionUnavailable | undefined @@ -93,6 +96,7 @@ export async function retireSignedOutStructuredAgentSessionChild( ): Promise { if ( !session || + session.child?.startedFor === startedFor || !structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) || structuredAgentSessionChildHasOpenWork(session.journal, deps.work) || owesWakeUp(session.journal, deps.work.childWork()) diff --git a/src/main/pi/pi-scripted-child.test-fixture.ts b/src/main/pi/pi-scripted-child.test-fixture.ts index 0f069f257c09..9e77d9f2d2ef 100644 --- a/src/main/pi/pi-scripted-child.test-fixture.ts +++ b/src/main/pi/pi-scripted-child.test-fixture.ts @@ -28,7 +28,9 @@ class ScriptedPiConnection implements PiRpcConnection { private readonly received: string[], private readonly wire: string[], private readonly onClosed: () => void, - number: number + number: number, + /** A Pi with no login: it lists no model and fails every prompt as not signed in. */ + private readonly signedOut = false ) { this.pid = 42_000 + number const sessionIndex = launch.args.indexOf('--session') @@ -55,7 +57,7 @@ class ScriptedPiConnection implements PiRpcConnection { return this.state() } if (command === 'get_available_models') { - return { models: [this.model] } + return { models: this.signedOut ? [] : [this.model] } } if (command === 'get_commands') { return { commands: [] } @@ -85,6 +87,15 @@ class ScriptedPiConnection implements PiRpcConnection { } 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' }) @@ -172,7 +183,7 @@ class ScriptedPiConnection implements PiRpcConnection { private state() { return { sessionFile: this.file, - model: this.model, + model: this.signedOut ? null : this.model, thinkingLevel: this.effort, isStreaming: this.streaming, isCompacting: false, @@ -186,7 +197,7 @@ export type ScriptedPiChild = ScriptedAgentChild & { wire(): readonly string[] } -export const piScriptedChild = (): ScriptedPiChild => { +export const piScriptedChild = (options: { signedOut?: boolean } = {}): ScriptedPiChild => { const children: ScriptedPiConnection[] = [] const received: string[] = [] const wire: string[] = [] @@ -230,7 +241,8 @@ export const piScriptedChild = (): ScriptedPiChild => { () => { closed += 1 }, - children.length + 1 + children.length + 1, + options.signedOut ) children.push(child) return child 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 000000000000..f35cd7b4657c --- /dev/null +++ b/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts @@ -0,0 +1,94 @@ +// A message that starts a signed-out Pi reaches it once and is settled by Pi's own refusal; the +// start made for it is never replaced as a signed-out child, which would start Pi for it forever. + +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 { + 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 { + 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 first message of a signed-out Pi as not signed in, and starts Pi once', 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, as a real Pi does. + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + 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' + ) + // Nothing starts Pi again for a message it already refused. + await new Promise((resolve) => setTimeout(resolve, 500)) + expect(script.spawns()).toBe(1) + expect(script.closes()).toBe(0) + expect(new Set(script.prompts())).toEqual(new Set(['hello, signed-out Pi'])) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index 62bad6316a9f..e5c2f18a623a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -51,7 +51,15 @@ import { getDefaultSettings } from '../../../../shared/constants' import { useAppStore } from '../../store' import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence' -import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' +import { + getStructuredLaunchStateBySessionId, + resetStructuredAgentLaunchRegistryForTests +} from '@/lib/structured-agent-session-launch-registry' +import { + holdStructuredAgentSessionLaunchOption, + settleStructuredLaunchCreateOptions +} from '@/lib/structured-agent-session-launch-options' +import { settleStructuredLaunchCallers } from '@/lib/structured-agent-session-launch-callers' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSession } from './use-structured-agent-session' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' @@ -161,6 +169,30 @@ describe('a chat pane over its own launch', () => { ) }) + it('keeps showing the picks its create carried once the launch is gone, while the agent starts', async () => { + saveSelection('gpt-5.5') + const { sessionId } = startStructuredAgentLaunch('wt-picks', 'codex', { + requestId: 'request-4' + }) + const { result } = renderHook(() => useNativeChatProvisionalLaunch('wt-picks', sessionId)) + act(() => { + void holdStructuredAgentSessionLaunchOption(sessionId, 'model', 'gpt-5.6-luna') + }) + expect(result.current.launch?.heldOptions).toEqual({ model: 'gpt-5.6-luna' }) + const state = getStructuredLaunchStateBySessionId(sessionId) + if (!state) { + throw new Error('launch missing') + } + // Publication settles the picks create carried and retires the launch in one step, before + // the view renders again; the chat's agent then starts with them. + act(() => { + settleStructuredLaunchCreateOptions(state, { model: 'gpt-5.6-luna' }) + settleStructuredLaunchCallers(state.callers, 'published') + }) + expect(getStructuredLaunchStateBySessionId(sessionId)).toBeUndefined() + expect(result.current.launch?.seedOptions).toMatchObject({ model: 'gpt-5.6-luna' }) + }) + it('shows a pick the launch could not apply the way a refused option change is shown', async () => { saveSelection('gpt-5.5') let created!: (receipt: { sessionId: string; fence: number }) => void diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 193037770d4d..7588e6b27feb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -28,6 +28,29 @@ type LatchedLaunch = { seed: Readonly> | undefined } +function sameOptions( + left: Readonly> | undefined, + right: Readonly> | undefined +): boolean { + const leftKeys = Object.keys(left ?? {}) + return ( + leftKeys.length === Object.keys(right ?? {}).length && + leftKeys.every((key) => left?.[key] === right?.[key]) + ) +} + +/** What the launch says the host will run: its seed under the picks it holds. Publication settles + * held picks into the seed and retires the launch in one step, so the view may never see that + * seed; latching the picks keeps them shown while the agent starts. */ +function launchPicks(selection: { + seed?: Readonly> + held: Readonly> +}): Readonly> | undefined { + return Object.keys(selection.held).length > 0 + ? { ...selection.seed, ...selection.held } + : selection.seed +} + /** The launch this view started, latched: its record is deleted on publish, and a reopened chat * runs its own options. The seed follows the launch while it lives (a retry or accepted pick). */ function useLatchedLaunchView( @@ -40,12 +63,12 @@ function useLatchedLaunchView( launching ? { kind: getStructuredAgentSessionLaunchResumes(sessionId) ? 'resume' : 'new', - seed: selection?.seed + seed: selection ? launchPicks(selection) : undefined } : null ) - if (latched && selection && selection.seed !== latched.seed) { - setLatched({ kind: latched.kind, seed: selection.seed }) + if (latched && selection && !sameOptions(launchPicks(selection), latched.seed)) { + setLatched({ kind: latched.kind, seed: launchPicks(selection) }) } const held = selection?.held ?? NO_HELD_OPTIONS return useMemo( From 05ccab60e2e146b809f4303c3496457a07b11be5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:00:29 -0700 Subject: [PATCH 06/10] fix(native-chat): keep a chat's picks shown while its next runtime starts The picker's option state was reset to the listing's default at every fence, and a starting new chat is not read. With the agent published at spawn the fence moves before the handshake, so a pick made at rest showed the default for the whole start. Keep the same chat's state across its fences until the new runtime's own read answers. --- ...e-structured-agent-session-option-state.ts | 14 +- ...session-options.start-keeps-picks.test.tsx | 148 ++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.start-keeps-picks.test.tsx diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index a2ec9d0c1efa..61e5e014badb 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -14,6 +14,7 @@ import { import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' +import { cloneNativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' import { createCoalescedPollRunner, type CoalescedPollRunner @@ -32,7 +33,7 @@ function firstFrameOptionState( return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded } -/** The picker's option state for one session: seeded, reset at each fence, re-read each turn. */ +/** The picker's option state for one session: seeded, kept across its fences, re-read each turn. */ export function useStructuredAgentSessionOptionState(args: { agent: AgentType optionCatalog: AgentSessionOptionCatalog | null @@ -114,10 +115,15 @@ export function useStructuredAgentSessionOptionState(args: { seedsModel: launch.seedsModel, ...(launch.worktree ? { worktree: launch.worktree } : {}) }) - // A host answer is the account's, not the fence's: keep it rather than blank the default. + // The chat's next runtime starts with the picks the host saved, which this view last showed, + // and a starting agent is not read: keep them, and the account's list, until a read replaces them. const next = - sameSession && (previous.catalogSource === 'host' || previous.catalogSource === 'builtin') - ? { ...seeded, catalog: previous.catalog, catalogSource: previous.catalogSource } + sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null + ? { + ...previous, + record: cloneNativeChatSessionOptionRecord(previous.record), + pendingId: null + } : seeded optionMutationGeneration.current += 1 pendingOptionRef.current = null diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.start-keeps-picks.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.start-keeps-picks.test.tsx new file mode 100644 index 000000000000..babb7b3277cd --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.start-keeps-picks.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('./native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: vi.fn() +})) +vi.mock('@/lib/structured-agent-session-launch-options', () => ({ + holdStructuredAgentSessionLaunchOption: vi.fn(), + getStructuredAgentSessionLaunchSelection: () => null +})) + +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionsResult +} from '../../../../shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' + +// Windows QA: a new chat at rest, a model and effort picked in it, then the first message. The +// message starts the agent under a new fence, and while it starts the disabled pickers showed the +// listing's default instead of the pick, until the start was proven. + +const EFFORTS = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } +] + +const CASES = { + codex: { + listed: ['gpt-6.1-sol', 'gpt-6.1-terra'], + pick: { model: 'gpt-6.1-terra', effort: 'high' } + }, + grok: { listed: ['grok-build', 'grok-4.7'], pick: { model: 'grok-4.7', effort: 'low' } }, + pi: { + listed: ['openai/gpt-6.1-sol', 'anthropic/claude-sonnet'], + pick: { model: 'anthropic/claude-sonnet', effort: 'high' } + } +} as const + +const LOCAL = { kind: 'local' } as const + +type Pill = { model: string | null; effort: string | null } + +function pill(snapshot: readonly SessionOptionDescriptor[]): Pill { + const value = (id: string) => { + const entry = snapshot.find((descriptor) => descriptor.id === id) + return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null + } + return { model: value('model'), effort: value('effort') } +} + +beforeEach(() => { + mocks.call.mockReset() + resetHostModelCatalogSnapshotsForTests() +}) + +for (const [agent, entry] of Object.entries(CASES)) { + it(`keeps showing a ${agent} chat's pick while its first message starts the agent`, async () => { + const models = entry.listed.map((id, index) => ({ + id, + label: `${id} label`, + isDefault: index === 0, + efforts: EFFORTS, + ...(index === 0 ? { defaultEffort: 'medium' } : {}) + })) + const catalog: AgentSessionModelCatalogResult = { + origin: 'live-session', + models, + fetchedAt: 1_000, + listingNamesConfiguredModel: true + } + // The host's answer: the listing's default until the pick is saved, then the pick. + let saved: Readonly> = {} + const options = (): AgentSessionOptionsResult => ({ + models, + current: { model: saved.model ?? entry.listed[0], effort: saved.effort ?? 'medium' }, + rewind: { supported: false, reason: 'unsupported' } + }) + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' ? catalog : options() + ) + const mutate = vi.fn(async (_method: string, _op: string, fields: unknown) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook sends { key, value } to setOption. + const { key, value } = fields as { key: string; value: string } + saved = { ...saved, [key]: value } + return { key, value, options: saved } + }) + const frames: Pill[] = [] + // Stable across renders, as the pane's own props are. + const launch = { kind: 'new' as const, heldOptions: {} } + const { result, rerender, unmount } = renderHook( + (props: { fence: number; starting: boolean }) => { + const view = useStructuredAgentSessionOptions({ + agent, + sessionId: `${agent}-session`, + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + providerStarting: props.starting, + fence: props.fence, + turnId: null, + unloadedTurnRevisions: undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reply is the AgentSessionOptionResult shape the hook reads. + mutate: mutate as unknown as StructuredAgentSessionMutate, + launch + }) + frames.push(pill(view.optionSnapshot)) + return view + }, + { initialProps: { fence: 1, starting: false } } + ) + await waitFor(() => expect(pill(result.current.optionSnapshot).model).toBe(entry.listed[0])) + await act(async () => { + await result.current.setStructuredOption('model', entry.pick.model) + }) + await act(async () => { + await result.current.setStructuredOption('effort', entry.pick.effort) + }) + await waitFor(() => expect(pill(result.current.optionSnapshot)).toEqual(entry.pick)) + + // The first message: the host publishes the agent it is starting under the next fence. + const sent = frames.length + rerender({ fence: 2, starting: true }) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + // The start is proven and the host's live answer names the pick. + rerender({ fence: 2, starting: false }) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + expect(frames.slice(sent).filter((frame) => frame.model !== entry.pick.model)).toEqual([]) + expect(frames.slice(sent).filter((frame) => frame.effort !== entry.pick.effort)).toEqual([]) + unmount() + }) +} From 70febbb7216f3adf71a8dcb3bc68add1d41939a3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:33:43 -0700 Subject: [PATCH 07/10] Prove Codex workload exit and keep failed-start option reads passive --- .../codex-connection-tree-reaper.ts | 24 +++++ ...pp-server-connection-tree-unproven.test.ts | 24 +++-- .../codex-app-server-connection-types.ts | 3 + .../codex/codex-app-server-connection.test.ts | 4 +- src/main/codex/codex-app-server-connection.ts | 19 ++-- ...ex-app-server-teardown.integration.test.ts | 83 +++++++++++++++++- src/main/codex/codex-request-end-wait.test.ts | 4 +- src/main/codex/codex-request-end-wait.ts | 2 +- ...ured-journal-translation-subagents.test.ts | 38 ++++++++ .../codex-structured-journal-translation.ts | 10 ++- .../codex/codex-structured-session-adapter.ts | 4 + .../codex-structured-session-close.test.ts | 37 ++++++++ .../codex/codex-structured-session-close.ts | 26 +++++- .../codex/codex-structured-session-state.ts | 7 +- .../codex-structured-session-teardown.ts | 14 ++- src/main/codex/codex-subagent-executions.ts | 4 +- src/main/codex/codex-subagent-roster.ts | 4 +- ...gent-model-catalog-saved-only-read.test.ts | 45 ++++++++++ .../agent-model-catalog-service.ts | 4 +- ...structured-agent-session-adapter-router.ts | 32 +++++-- .../structured-agent-session-adapter.ts | 13 +++ .../structured-agent-session-child-close.ts | 15 +++- ...tured-agent-session-codex-stop-row.test.ts | 16 ++-- ...uctured-agent-session-options-read.test.ts | 87 ++++++++++++++++++- .../structured-agent-session-options-read.ts | 1 + 25 files changed, 464 insertions(+), 56 deletions(-) create mode 100644 src/main/codex/__fixtures__/codex-connection-tree-reaper.ts diff --git a/src/main/codex/__fixtures__/codex-connection-tree-reaper.ts b/src/main/codex/__fixtures__/codex-connection-tree-reaper.ts new file mode 100644 index 000000000000..cad5b6e7fc73 --- /dev/null +++ b/src/main/codex/__fixtures__/codex-connection-tree-reaper.ts @@ -0,0 +1,24 @@ +import { vi } from 'vitest' + +vi.mock('../../claude/claude-agent-sdk-exit-proof', () => ({ createClaudeChildTreeReaper })) + +export function createClaudeChildTreeReaper( + child: { kill: (signal: NodeJS.Signals) => boolean }, + deps: { exited: () => boolean } +) { + let verdict: 'exited' | 'unverifiable' = 'unverifiable' + return { + capture: async () => {}, + refresh: async () => {}, + reap: async () => { + if (!deps.exited()) { + child.kill('SIGKILL') + } + verdict = 'exited' + return verdict + }, + get treeVerdict() { + return verdict + } + } +} diff --git a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts index 599c98641b1c..850d470d242b 100644 --- a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts +++ b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts @@ -2,20 +2,27 @@ import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '@orca/process-host' -import type { ProviderProcessTeardownVerdict } from '../provider-process/provider-process-teardown' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { openCodexAppServerConnection } from './codex-app-server-connection' const teardown = vi.hoisted(() => { - const state: { verdict: ProviderProcessTeardownVerdict; rootExits: () => void } = { - verdict: null, + const state: { verdict: DescendantTreeVerdict; rootExits: () => void } = { + verdict: 'unverifiable', rootExits: () => {} } return state }) -vi.mock('../provider-process/provider-process-teardown', () => ({ - terminateProviderProcessTree: vi.fn(async () => { - teardown.rootExits() - return teardown.verdict +vi.mock('../claude/claude-agent-sdk-exit-proof', () => ({ + createClaudeChildTreeReaper: () => ({ + capture: async () => {}, + refresh: async () => {}, + reap: async () => { + teardown.rootExits() + return teardown.verdict + }, + get treeVerdict() { + return teardown.verdict + } }) })) @@ -44,8 +51,7 @@ describe('Codex process-tree diagnostic after a forced close', () => { it.each([ ['unverifiable', true], ['live', true], - ['exited', false], - [null, false] + ['exited', false] ] as const)('teardown observing %s reads unproven=%s', async (verdict, unproven) => { vi.useFakeTimers() teardown.verdict = verdict diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index 9e50a5233133..df87c560a78b 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -34,6 +34,9 @@ export type CodexAppServerConnection = { resumeReading?: () => void /** Resolves true only after the child emitted `exit` or `close`; false is unproven. */ close: () => Promise + /** The last close's separate root and workload observations. */ + readonly closeResult?: ProviderProcessCloseResult | null /** The root exited, but the forced kill of its process tree could not prove the tree gone. */ readonly processTreeUnproven?: boolean } +import type { ProviderProcessCloseResult } from '../provider-process/provider-process-close' diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index afe50c954bc3..bc5375ba28b7 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 './__fixtures__/codex-connection-tree-reaper' import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import type { EventEmitter } from 'node:events' import { realpathSync } from 'node:fs' @@ -17,7 +18,6 @@ import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-pr import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import { isCodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' -// close() waits out the supervisor's own stop before forcing the tree. const GRACEFUL_EXIT_MS = process.platform === 'win32' ? 1_500 : PROVIDER_SUPERVISOR_MAX_STOP_MS const originalCodexHome = process.env.CODEX_HOME @@ -552,7 +552,7 @@ describe('openCodexAppServerConnection', () => { await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 2_600) await expect(Promise.all([first, second])).resolves.toEqual([true, true]) - expect(child.kill.mock.calls.map(([signal]) => signal)).toEqual(['SIGSTOP', 'SIGKILL']) + expect(child.kill.mock.calls.map(([signal]) => signal)).toEqual(['SIGKILL']) }) it('allows a later close to observe exit after an unproven attempt', async () => { diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index fcabeea5f6b1..388e8555f42e 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -1,6 +1,7 @@ import type { PipedProcessSpawner } from '@orca/process-host/process-spec' import { spawnProcess } from '@orca/process-host' import { spawnManagedProviderProcess } from '../provider-process/managed-provider-process' +import { createClaudeChildTreeReaper } from '../claude/claude-agent-sdk-exit-proof' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import { buildCodexAppServerExitError } from './codex-app-server-exit-error' import { withMissingProviderExecutable } from '../provider-process/provider-executable-missing' @@ -49,6 +50,9 @@ async function connectCodexAppServer( ...(handlers.onOutput ? { onOutput: handlers.onOutput } : {}) }) const { child, terminateTree: terminateProcessTree } = managed + const tree = createClaudeChildTreeReaper(child, { + exited: () => managed.rootVerdict === 'exited' + }) let nextRequestId = 1 let closing = false @@ -84,7 +88,7 @@ async function connectCodexAppServer( // Transport/protocol failures make the connection unusable immediately so // callers do not hang, but recovery must not treat that as a child exit // until the execution host has observed `exit`/`close`. - if (managed.rootVerdict === 'exited' && !exitReported) { + if (managed.rootVerdict === 'exited' && !closing && !exitReported) { exitReported = true handlers.onExit?.(terminalError, { expected: closing }) } @@ -197,7 +201,11 @@ async function connectCodexAppServer( function close(): Promise { closing ||= managed.rootVerdict !== 'exited' - return managed.close().then((result) => { + return managed.close(tree).then((result) => { + if (result.root === 'exited' && !exitReported) { + exitReported = true + handlers.onExit?.(terminalError ?? buildExitError(), { expected: closing }) + } dispatcher.failPending(new Error('codex app-server connection closed')) return result.root === 'exited' }) @@ -212,9 +220,10 @@ async function connectCodexAppServer( }, get processTreeUnproven() { const tree = managed.lastCloseResult?.tree - return ( - managed.lastCloseResult?.root === 'exited' && (tree === 'unverifiable' || tree === 'live') - ) + return managed.lastCloseResult?.root === 'exited' && tree !== 'exited' + }, + get closeResult() { + return managed.lastCloseResult }, request, notify, diff --git a/src/main/codex/codex-app-server-teardown.integration.test.ts b/src/main/codex/codex-app-server-teardown.integration.test.ts index c2b09c664440..115104ceee33 100644 --- a/src/main/codex/codex-app-server-teardown.integration.test.ts +++ b/src/main/codex/codex-app-server-teardown.integration.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { execFileSync } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' import { openCodexAppServerConnection, @@ -7,6 +8,35 @@ import { const ITERATIONS = 40 +const DETACHED_GRANDCHILD_APP_SERVER = String.raw` + const { spawn } = require('node:child_process') + const readline = require('node:readline') + const grandchild = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 60000)"], { + detached: true, + stdio: 'ignore' + }) + 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') send({ id: message.id, result: {} }) + if (message.method === 'initialized') { + send({ method: 'test/grandchild', params: { provider: process.pid, grandchild: grandchild.pid } }) + } + }) + process.on('SIGTERM', () => {}) + setInterval(() => {}, 60000) +` + +function processIdentity(pid: number): string | null { + try { + return execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], { + encoding: 'utf8' + }).trim() + } catch { + return null + } +} + const FORCE_KILL_APP_SERVER = String.raw` const { spawn } = require('node:child_process') const readline = require('node:readline') @@ -88,6 +118,51 @@ async function cleanupServer(server: RunningServer): Promise { } describe.runIf(process.platform !== 'win32')('Codex app-server process teardown', () => { + it('reaps a grandchild in its own session after an unresponsive provider root', async () => { + const observed = Promise.withResolvers<{ provider: number; grandchild: number }>() + const connection = await openCodexAppServerConnection( + { command: process.execPath, args: ['-e', DETACHED_GRANDCHILD_APP_SERVER] }, + { + onNotification: (method, params) => { + if ( + method === 'test/grandchild' && + typeof params === 'object' && + params !== null && + 'provider' in params && + 'grandchild' in params && + typeof params.provider === 'number' && + typeof params.grandchild === 'number' + ) { + observed.resolve({ provider: params.provider, grandchild: params.grandchild }) + } + } + } + ) + const pids = await observed.promise + const identities = [connection.pid, pids.provider, pids.grandchild] + .filter((pid): pid is number => typeof pid === 'number') + .map((pid) => ({ pid, identity: processIdentity(pid) })) + try { + expect(processExists(pids.grandchild)).toBe(true) + const rootExited = await connection.close() + expect(rootExited).toBe(true) + if (connection.closeResult?.tree === 'exited') { + await vi.waitFor(() => expect(processExists(pids.grandchild)).toBe(false)) + expect(connection.closeResult).toMatchObject({ root: 'exited', tree: 'exited' }) + } else { + expect(connection.closeResult?.tree).not.toBe('exited') + expect(connection.processTreeUnproven).toBe(true) + } + } finally { + await connection.close().catch(() => false) + for (const { pid, identity } of identities) { + if (identity && processIdentity(pid) === identity) { + process.kill(pid, 'SIGKILL') + } + } + } + }, 30_000) + it('reaps the forced-close descendant in 40 consecutive launches', async () => { const running: RunningServer[] = [] try { @@ -108,7 +183,7 @@ describe.runIf(process.platform !== 'win32')('Codex app-server process teardown' }, 30_000) it.each(['normal', 'signal'] as const)( - 'reaps provider descendants before relaying a %s root exit in 40 consecutive launches', + 'does not claim an unobserved tree clean after a %s root exit in 40 launches', async (exitMode) => { for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { const server = await openServer(iteration, exitMode) @@ -117,6 +192,10 @@ describe.runIf(process.platform !== 'win32')('Codex app-server process teardown' expect(processExists(server.supervisorPid)).toBe(false) expect(processExists(server.descendantPid)).toBe(false) await expect(server.connection.close()).resolves.toBe(true) + expect(server.connection.closeResult).toMatchObject({ + root: 'exited', + tree: 'unverifiable' + }) } finally { await cleanupServer(server) } diff --git a/src/main/codex/codex-request-end-wait.test.ts b/src/main/codex/codex-request-end-wait.test.ts index b4f00da960c8..422044800a45 100644 --- a/src/main/codex/codex-request-end-wait.test.ts +++ b/src/main/codex/codex-request-end-wait.test.ts @@ -35,7 +35,7 @@ it('lets a Stop wait for the interrupted turn to end, without waiting on its mis await adapter.closeAll() }) -it('spends only the remaining two-second budget from the Stop timestamp', async () => { +it('spends only the remaining five-second budget from the Stop timestamp', async () => { const codex = fakeCodex() codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') const adapter = await acquired(codex) @@ -51,7 +51,7 @@ it('spends only the remaining two-second budget from the Stop timestamp', async const waiting = adapter.awaitStoppedRequestEnd('session-1', stoppedAt).then(() => { ended = true }) - await vi.advanceTimersByTimeAsync(1_499) + await vi.advanceTimersByTimeAsync(4_499) expect(ended).toBe(false) await vi.advanceTimersByTimeAsync(1) await waiting diff --git a/src/main/codex/codex-request-end-wait.ts b/src/main/codex/codex-request-end-wait.ts index a48ccd2d11d0..b51cd5e7e58e 100644 --- a/src/main/codex/codex-request-end-wait.ts +++ b/src/main/codex/codex-request-end-wait.ts @@ -1,7 +1,7 @@ import type { CodexSession } from './codex-structured-session-state' import { codexStopTarget } from './codex-structured-turn-open-wait' -export const CODEX_STOP_GRACE_MS = 2_000 +export const CODEX_STOP_GRACE_MS = 5_000 const waiters = new WeakMap void>>() function requestInFlight(session: CodexSession): boolean { diff --git a/src/main/codex/codex-structured-journal-translation-subagents.test.ts b/src/main/codex/codex-structured-journal-translation-subagents.test.ts index 8db30d31b217..d21f306e0ebc 100644 --- a/src/main/codex/codex-structured-journal-translation-subagents.test.ts +++ b/src/main/codex/codex-structured-journal-translation-subagents.test.ts @@ -181,4 +181,42 @@ describe('codex journal translation — subagents', () => { expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', state: 'unverifiable' }]) }) + + it.each([ + [{ root: 'exited', workload: 'exited' }, 'stopped'], + [{ root: 'exited', workload: 'unverifiable' }, 'unverifiable'], + [{ root: 'live', workload: 'exited' }, 'unverifiable'] + ] as const)('settles a requested Stop with process evidence %j as %s', (processExit, state) => { + const { translator, rows } = harness() + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + deliverActivity(translator, subagentItem('started', 'child-1', '/root/read')) + translator.handle({ + type: 'ended', + sessionId: SESSION_ID, + reason: 'stopped', + cause: 'requested-close', + stopCause: 'user-stop', + processExit, + fence: 1, + acquisitionGeneration: 'gen-1' + }) + expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', state }]) + }) + + it('leaves a proven user-close child unavailable rather than calling it interrupted', () => { + const { translator, rows } = harness() + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + deliverActivity(translator, subagentItem('started', 'child-1', '/root/read')) + translator.handle({ + type: 'ended', + sessionId: SESSION_ID, + reason: 'chat closed', + cause: 'requested-close', + stopCause: 'user-close', + processExit: { root: 'exited', workload: 'exited' }, + fence: 1, + acquisitionGeneration: 'gen-1' + }) + expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', state: 'unverifiable' }]) + }) }) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index e36a661ecd11..749ec9c4fa85 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -174,7 +174,15 @@ export function createCodexJournalTranslator( return admission } // No event will ever settle a child once the provider is gone. - const sweep = subagents.settleSession() + const provenStopped = + 'cause' in event && + event.cause === 'requested-close' && + 'stopCause' in event && + event.stopCause === 'user-stop' && + 'processExit' in event && + event.processExit?.root === 'exited' && + event.processExit.workload === 'exited' + const sweep = subagents.settleSession(provenStopped) if (!sweep.accepted) { return sweep } diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 2052eaaf00b8..caa55a51d022 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -314,6 +314,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + stopSessionWithCause: NonNullable = ( + sessionId, + cause + ) => this.teardown.close(sessionId, { cause }) closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index cb3121fad606..2e1da31a3969 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -158,6 +158,30 @@ describe('Codex structured session close lifecycle', () => { expect(released).toBe(true) }) + it('carries separate root and workload exit evidence into a requested end', () => { + const { connection, translator, sessions, session } = backpressuredSession(true) + if (session.orcaClose) { + session.orcaClose.stopCause = 'user-stop' + } + Object.defineProperty(connection, 'closeResult', { + value: { root: 'exited', tree: 'unverifiable' } + }) + handleCodexSessionExit({ + sessions, + sessionId: 'session-1', + connection, + error: new Error('closed'), + closedByOrca: true + }) + expect(translator.handle).toHaveBeenCalledWith( + expect.objectContaining({ + cause: 'requested-close', + stopCause: 'user-stop', + processExit: { root: 'exited', workload: 'unverifiable' } + }) + ) + }) + it('mints a distinct child generation even when acquisitions share one fence', async () => { const { adapter } = adapterFixture() const input = { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' } @@ -199,6 +223,19 @@ describe('Codex structured session close lifecycle', () => { ]) }) + it('preserves the host Stop cause through the adapter close', async () => { + const { adapter, events } = adapterFixture() + await acquireReadyCodexForTest(adapter, { + identity: identity('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) + await expect(adapter.stopSessionWithCause('session-1', 'user-stop')).resolves.toBe(true) + expect(events.filter((event) => event.type === 'ended')).toMatchObject([ + { cause: 'requested-close', stopCause: 'user-stop' } + ]) + }) + it('force-close preserves unexpected-exit evidence when the adapter reports exit during close', async () => { const { adapter, connections, events } = adapterFixture() await acquireReadyCodexForTest(adapter, { diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 659b5d206911..3dc41537941f 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -9,7 +9,10 @@ import { type CodexSession, type CodexStructuredSessionEvent } from './codex-structured-session-state' -import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionEndedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' export function handleCodexSessionExit(input: { @@ -47,6 +50,17 @@ export function handleCodexSessionExit(input: { ? agentSessionFailureFact('hostFault') : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.orcaClose?.requested ? 'requested-close' : 'unexpected-exit', + ...(session.orcaClose?.requested && session.orcaClose.stopCause + ? { stopCause: session.orcaClose.stopCause } + : {}), + ...(input.closedByOrca && input.connection?.closeResult + ? { + processExit: { + root: input.connection.closeResult.root, + workload: input.connection.closeResult.tree + } + } + : {}), fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt, @@ -93,6 +107,7 @@ export async function closeCodexPublishedSession( options?: { logger?: StructuredAgentSessionLogger requestedClose?: boolean + stopCause?: StructuredAgentSessionStopCause expectedFence?: number expectedAcquisitionGeneration?: string unexpectedReason?: Error @@ -111,9 +126,10 @@ export async function closeCodexPublishedSession( } // 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') + reason: options?.unexpectedReason ?? new Error('codex session closed'), + ...(options?.stopCause ? { stopCause: options.stopCause } : {}) } // Keep the session indexed until the child exit is observed. A timeout or // failed kill must leave the live connection available for a safe retry. @@ -143,7 +159,8 @@ export async function closeCodexSession( acquisitions: CodexAcquisitionRegistry, onEvent?: (event: CodexStructuredSessionEvent) => void, logger?: StructuredAgentSessionLogger, - requestedClose = true + requestedClose = true, + stopCause?: StructuredAgentSessionStopCause ): Promise { const attempt = acquisitions.get(sessionId) if (!(await cancelCodexAcquisitionAttempt(attempt, requestedClose))) { @@ -154,6 +171,7 @@ export async function closeCodexSession( } return closeCodexPublishedSession(sessions, sessionId, onEvent, { requestedClose, + ...(stopCause ? { stopCause } : {}), ...(logger ? { logger } : {}) }) } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 9fe85abceacb..fd806c7b7dc4 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -21,7 +21,10 @@ 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 { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionLifecycleEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { AgentModelCatalogSessionAccess, @@ -130,7 +133,7 @@ export type CodexSession = { exitObservedAt?: number /** The close Orca began for this child: asked for, or forced as a death, and why. Whatever ends * the child after it (that close, or the exit the connection reports meanwhile) keeps this. */ - orcaClose?: { requested: boolean; reason: Error } + orcaClose?: { requested: boolean; reason: Error; stopCause?: StructuredAgentSessionStopCause } fence: number acquisitionGeneration: string threadId: string diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index 12316dcbe800..43f8afa0401f 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -4,7 +4,10 @@ // session owns are cleared exactly once, and only when the child was actually // proven stopped — a refused close leaves the session indexed for a retry. -import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + AgentSessionAcquisitionRootExitObservedError, + type StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { closeAllCodexSessions, @@ -28,7 +31,10 @@ export type CodexStructuredSessionTeardownDeps = { export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} - close = async (sessionId: string): Promise => { + close = async ( + sessionId: string, + options?: { cause?: StructuredAgentSessionStopCause } + ): Promise => { const connection = this.deps.sessions.get(sessionId)?.connection ?? this.deps.acquisitions.get(sessionId)?.window.connection @@ -39,7 +45,9 @@ export class CodexStructuredSessionTeardown { this.deps.sessions, this.deps.acquisitions, this.deps.onEvent, - this.deps.logger + this.deps.logger, + true, + options?.cause ) ) if (closed && connection?.processTreeUnproven) { diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 2bdcb3a35bfc..2477cc566d19 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -168,10 +168,10 @@ export class CodexSubagentExecutions { ) } - settleSession(): void { + settleSession(state: 'stopped' | 'unverifiable' = 'unverifiable'): void { for (const child of this.children.values()) { if (child.execution?.state === 'working') { - child.execution = { ...child.execution, state: 'unverifiable' } + child.execution = { ...child.execution, state } this.changed(child) } } diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index ac00a0cafbc4..931229bc8b34 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -190,8 +190,8 @@ export class CodexSubagentRoster { * This is the ONLY sweep. A turn ending is not one: `spawn_agent` children * routinely outlive their turn and keep reporting into the same group. */ - settleSession(): StructuredAgentSessionSinkAdmission { - this.executions.settleSession() + settleSession(provenStopped = false): StructuredAgentSessionSinkAdmission { + this.executions.settleSession(provenStopped ? 'stopped' : 'unverifiable') return this.tracker.settleSession() } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts index 9dcec6070fc2..7b9079ff8893 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' import { agentModelCatalogFingerprint } from './agent-model-catalog-fingerprint' import { createAgentModelCatalogService } from './agent-model-catalog-service' import { @@ -48,6 +52,47 @@ function rig(agent = 'grok') { } describe('a saved-only catalog read', () => { + it('does not relaunch a released chat to read models after startup failed', async () => { + let now = 1_000 + const record = { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ claimStatus: 'released', ownerProcess: null }) + ), + provider: 'grok', + accountHome: HOME + } + const store = new AgentModelCatalogStore({ now: () => now }) + const probe = vi.fn(async () => listing('grok-4.7')) + const service = createAgentModelCatalogService({ + store, + getRecord: (sessionId) => (sessionId === record.sessionId ? record : undefined), + drivesRecord: () => true, + resolveAccountHome: async () => HOME, + probes: { grok: probe } + }) + + expect(await service.read({ agent: 'grok', sessionId: record.sessionId })).toEqual({ + origin: 'unknown' + }) + expect( + await service.read({ agent: 'grok', sessionId: record.sessionId, waitForListing: true }) + ).toEqual({ + origin: 'unknown' + }) + expect(probe).not.toHaveBeenCalled() + + store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery') + now += AGENT_MODEL_CATALOG_FRESH_MS + expect(await service.read({ agent: 'grok', sessionId: record.sessionId })).toMatchObject({ + origin: 'probe', + models: [{ id: 'grok-4.7' }] + }) + expect(probe).not.toHaveBeenCalled() + + await service.read({ agent: 'grok' }) + expect(probe).toHaveBeenCalledOnce() + }) + it('answers from the saved entry and starts nothing, however old the entry is', async () => { const { store, probe, service, advance } = rig() store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery') diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index de5a5c26b60c..d4316b146d63 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -274,7 +274,9 @@ export function createAgentModelCatalogService( ...(unavailable ? { unavailable } : {}) } } - if (params.savedOnly) { + // A released chat has no running agent to refresh its list; a read after a failed start + // must not launch another copy of the same agent. + if (params.savedOnly || scoped?.lease?.claimStatus === 'released') { return answer(entry) } // Past its TTL, only the probe re-derives a held reason. The reason is served meanwhile; 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 5a43516178b9..bcdf0036eaa8 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 @@ -4,7 +4,10 @@ import type { AgentSessionAccountHome, AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionStopCause +} from './structured-agent-session-adapter' import type { StructuredAgentRegistration, StructuredAgentRegistry @@ -178,19 +181,31 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi Promise.resolve(null) closeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.closeSession) + this.stopSession(sessionId, (adapter) => adapter.closeSession?.(sessionId)) forceCloseSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) + this.stopSession(sessionId, (adapter) => + (adapter.forceCloseSession ?? adapter.closeSession)?.call(adapter, sessionId) + ) disposeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) + this.stopSession(sessionId, (adapter) => + (adapter.disposeSession ?? adapter.closeSession)?.call(adapter, sessionId) + ) + + stopSessionWithCause = ( + sessionId: string, + cause?: StructuredAgentSessionStopCause + ): Promise => + this.stopSession(sessionId, (adapter) => + adapter.stopSessionWithCause + ? adapter.stopSessionWithCause(sessionId, cause) + : (adapter.disposeSession ?? adapter.closeSession)?.call(adapter, sessionId) + ) private async stopSession( sessionId: string, - selectStop: ( - adapter: StructuredAgentSessionAdapter - ) => NonNullable | undefined + stop: (adapter: StructuredAgentSessionAdapter) => Promise | undefined ): Promise { const route = this.routes.get(sessionId) if (!route) { @@ -203,8 +218,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return true } const { adapter } = route.registration - const stop = selectStop(adapter) - const stopped = await stop?.call(adapter, sessionId) + const stopped = await stop(adapter) if (stopped === true) { route.state = 'stopped' return true 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 843b9129de29..b69eae8dec4c 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 @@ -42,6 +42,7 @@ import type { AgentSessionCancelOutcome, StructuredAgentSessionAdapterStop } from './structured-agent-session-adapter-stop' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause' export type { AgentSessionCancelOutcome } from './structured-agent-session-adapter-stop' export type { StructuredAgentSessionChildEndCause, @@ -192,6 +193,13 @@ export type StructuredAgentSessionEndedEvent = { * Orca fault. Absent reads as a provider exit with nothing to add. */ failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' + /** The host's stop cause, when this end came from a requested close. */ + stopCause?: StructuredAgentSessionStopCause + /** Execution-host observations from a requested provider close, when available. */ + processExit?: { + root: 'live' | 'unverifiable' | 'exited' + workload: 'live' | 'unverifiable' | 'exited' | null + } fence: number acquisitionGeneration: string /** Host receipt of the child exit: the end time of a turn it interrupted. */ @@ -422,6 +430,11 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & forceCloseSession?(sessionId: string): Promise /** Stops a provider child for teardown without requiring a future-resume cursor. */ disposeSession?(sessionId: string): Promise + /** Host stop cause for adapters that classify child work at exit. */ + stopSessionWithCause?( + sessionId: string, + cause?: StructuredAgentSessionStopCause + ): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ acknowledgeSessionRelease?(sessionId: string): void } 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 bc4fdf530c5b..03b455e8929d 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 @@ -76,12 +76,21 @@ function closeProviderRoot( ): Promise { const { adapter, logger } = context.deps // An adapter with no close has nothing to stop; anything else must PROVE the exit. - const stop = adapter.disposeSession ?? adapter.closeSession - if (!stop) { + if (!adapter.stopSessionWithCause && !adapter.disposeSession && !adapter.closeSession) { return Promise.resolve(true) } return stopAgentSessionProviderRoot( - () => stop.call(adapter, sessionId), + async () => { + if (adapter.stopSessionWithCause) { + return adapter.stopSessionWithCause( + sessionId, + context.sessions.get(sessionId)?.child?.close?.cause + ) + } + return ( + (await (adapter.disposeSession ?? adapter.closeSession)?.call(adapter, sessionId)) === true + ) + }, // The exit is proven; a child process left behind, or a write after it, is reported only. (error) => logger.warn("the agent's process exited, but its close did not finish cleanly", { 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 50bb803f6ac6..31546e03a041 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 @@ -41,7 +41,9 @@ let notify: (method: string, params: unknown) => void /** Read at each start, so a test can say what the next start resumes. */ let launch: { resumeThreadId?: string | null } let log: ReturnType -let disposeSession: MockInstance> +let stopSessionWithCause: MockInstance< + NonNullable +> beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-stop-row-')) @@ -75,7 +77,7 @@ beforeEach(async () => { } } }) - disposeSession = vi.spyOn(adapter, 'disposeSession') + stopSessionWithCause = vi.spyOn(adapter, 'stopSessionWithCause') host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: (log = recordingStructuredAgentSessionLogger()).logger, @@ -178,7 +180,7 @@ async function followUpUnopened(): Promise { /** Whether the Stop ended the child: the host's stop, which proves the exit. Nothing else here * stops it before the test's teardown. */ function childEndedByStop(): boolean { - return disposeSession.mock.calls.length > 0 + return stopSessionWithCause.mock.calls.length > 0 } /** The Stop's second step, which ends the child once Codex has wound down, has run. */ @@ -228,7 +230,7 @@ describe('a Codex Stop whose interrupt failed', () => { await stopSettled() expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + expect(stopSessionWithCause).toHaveBeenCalledExactlyOnceWith(SESSION, 'user-stop') expect(codex.connections.at(-1)?.closed).toBe(true) const rows = await journalRows() expect(rows.turns).toEqual(['interrupted']) @@ -243,13 +245,13 @@ describe('a Codex Stop whose interrupt failed', () => { codex.routes['turn/interrupt'] = () => { throw interruptFailure('internal error') } - disposeSession.mockResolvedValueOnce(false) + stopSessionWithCause.mockResolvedValueOnce(false) const stopped = await stop('turn-1') await stopSettled() expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + expect(stopSessionWithCause).toHaveBeenCalledExactlyOnceWith(SESSION, 'user-stop') const rows = await journalRows() expect(rows.turns).toEqual(['running']) expect(rows.statuses).toEqual(['Cancellation was not confirmed.']) @@ -446,7 +448,7 @@ describe('a Codex Stop whose interrupt failed', () => { await stopSettled() expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + expect(stopSessionWithCause).toHaveBeenCalledExactlyOnceWith(SESSION, 'user-stop') await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop', 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 3bba13ff774d..471b1c2ca089 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 @@ -25,6 +25,91 @@ function restingRecord(): AgentSessionRecord { } describe('options at rest', () => { + it('uses the folder default while the child is still starting', async () => { + const record = restingRecord() + const read = vi.fn(async () => ({ + origin: 'probe' as const, + models: [ + { id: 'gpt-folder', label: 'Folder', isDefault: true, defaultEffort: 'high', efforts: [] } + ], + fetchedAt: 1 + })) + const prepareReadOptions = vi.fn() + const starting = { + child: { phase: 'starting' }, + params: { provider: 'codex' }, + journal: { context: { floor: () => null } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. + const context = { + deps: { + adapter: { prepareReadOptions }, + agents: NO_STRUCTURED_AGENTS, + store: { getRecord: () => record }, + modelCatalog: { read } + }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => starting, + conversation: async () => starting + } as unknown as StructuredAgentSessionMutationContext + + const result = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: true + }) + expect(result.current).toEqual({ model: 'gpt-folder', effort: 'high' }) + expect(read).toHaveBeenCalledWith({ + agent: 'codex', + sessionId: SESSION, + inSessionWorkspace: true, + savedOnly: true + }) + expect(prepareReadOptions).not.toHaveBeenCalled() + }) + + it('keeps the account model for an older client when the starting child has no folder default', async () => { + const record = restingRecord() + const read = vi.fn(async (params: { inSessionWorkspace?: true }) => ({ + origin: 'probe' as const, + models: [ + { + id: params.inSessionWorkspace ? 'gpt-folder' : 'gpt-account', + label: 'Model', + isDefault: !params.inSessionWorkspace, + efforts: [] + } + ], + fetchedAt: 1 + })) + const starting = { + child: { phase: 'starting' }, + params: { provider: 'codex' }, + journal: { context: { floor: () => null } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. + const context = { + deps: { + adapter: {}, + agents: NO_STRUCTURED_AGENTS, + store: { getRecord: () => record }, + modelCatalog: { read } + }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => starting, + conversation: async () => starting + } as unknown as StructuredAgentSessionMutationContext + + const current = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: true + }) + expect(current.current).toEqual({}) + expect(read).toHaveBeenCalledTimes(1) + const older = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: false + }) + expect(older.current).toEqual({ model: 'gpt-account' }) + expect(read).toHaveBeenLastCalledWith({ agent: 'codex', sessionId: SESSION, savedOnly: true }) + }) + it.each(['codex', 'omp', 'grok', 'opencode'] as const)( 'omits an unknown model for %s while the catalog is unavailable', async (provider) => { @@ -58,7 +143,7 @@ describe('options at rest', () => { const result = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - expect(probe).toHaveBeenCalledTimes(provider === 'codex' ? 1 : 0) + expect(probe).not.toHaveBeenCalled() expect(result.models).toEqual([]) expect(result.current).not.toHaveProperty('model') record.options = { model: 'later-model', effort: 'off' } 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 9bbf908f9b4c..8ff523579ef1 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 @@ -75,6 +75,7 @@ async function readStructuredAgentSessionOptionsAtRest( ?.read({ agent: record.provider, sessionId, + savedOnly: true, ...(inSessionWorkspace ? { inSessionWorkspace: true as const } : {}) }) .catch(() => null)) ?? { origin: 'unknown' } From e7fb0015a05b1a23e6d160c5102f61d23a936a03 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:37:04 -0700 Subject: [PATCH 08/10] Report unobserved Codex workload exit as unverifiable --- src/main/codex/codex-structured-session-close.ts | 2 +- .../agent-session-wire/structured-agent-session-adapter.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 3dc41537941f..a8f539285738 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -57,7 +57,7 @@ export function handleCodexSessionExit(input: { ? { processExit: { root: input.connection.closeResult.root, - workload: input.connection.closeResult.tree + workload: input.connection.closeResult.tree ?? 'unverifiable' } } : {}), 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 b69eae8dec4c..f0278b08b30e 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 @@ -198,7 +198,7 @@ export type StructuredAgentSessionEndedEvent = { /** Execution-host observations from a requested provider close, when available. */ processExit?: { root: 'live' | 'unverifiable' | 'exited' - workload: 'live' | 'unverifiable' | 'exited' | null + workload: 'live' | 'unverifiable' | 'exited' } fence: number acquisitionGeneration: string From 18ec576514ec4bf7a696dff483082b10efbe001f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:53:22 -0700 Subject: [PATCH 09/10] Keep connection mocks in tests and extract the scripted server fixture --- .../codex-connection-app-server.ts | 30 ++++++++++ .../codex-connection-tree-reaper.ts | 24 -------- .../codex/codex-app-server-connection.test.ts | 55 ++++++++----------- 3 files changed, 54 insertions(+), 55 deletions(-) create mode 100644 src/main/codex/__fixtures__/codex-connection-app-server.ts delete mode 100644 src/main/codex/__fixtures__/codex-connection-tree-reaper.ts 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 000000000000..f14a91b69252 --- /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/__fixtures__/codex-connection-tree-reaper.ts b/src/main/codex/__fixtures__/codex-connection-tree-reaper.ts deleted file mode 100644 index cad5b6e7fc73..000000000000 --- a/src/main/codex/__fixtures__/codex-connection-tree-reaper.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { vi } from 'vitest' - -vi.mock('../../claude/claude-agent-sdk-exit-proof', () => ({ createClaudeChildTreeReaper })) - -export function createClaudeChildTreeReaper( - child: { kill: (signal: NodeJS.Signals) => boolean }, - deps: { exited: () => boolean } -) { - let verdict: 'exited' | 'unverifiable' = 'unverifiable' - return { - capture: async () => {}, - refresh: async () => {}, - reap: async () => { - if (!deps.exited()) { - child.kill('SIGKILL') - } - verdict = 'exited' - return verdict - }, - get treeVerdict() { - return verdict - } - } -} diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index bc5375ba28b7..3c1d93f7b8c3 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -1,4 +1,4 @@ -import './__fixtures__/codex-connection-tree-reaper' +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' @@ -18,6 +18,29 @@ import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-pr import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import { isCodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' +vi.mock('../claude/claude-agent-sdk-exit-proof', () => ({ createClaudeChildTreeReaper })) + +function createClaudeChildTreeReaper( + child: { kill: (signal: NodeJS.Signals) => boolean }, + deps: { exited: () => boolean } +) { + let verdict: 'exited' | 'unverifiable' = 'unverifiable' + return { + capture: async () => {}, + refresh: async () => {}, + reap: async () => { + if (!deps.exited()) { + child.kill('SIGKILL') + } + verdict = 'exited' + return verdict + }, + get treeVerdict() { + return verdict + } + } +} + const GRACEFUL_EXIT_MS = process.platform === 'win32' ? 1_500 : PROVIDER_SUPERVISOR_MAX_STOP_MS const originalCodexHome = process.env.CODEX_HOME @@ -37,36 +60,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 = {}, From 34e53888a3db3304957523f22f4f1b036f75a25d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:26:06 -0700 Subject: [PATCH 10/10] Fix the queued Stop test clock in CI The queue test waited for its lane before advancing the fake provider's wind-down check, so CI timed out. Advance the existing five-millisecond timer after the accepted turn settles, preserving the behavior assertions. --- .../structured-agent-session-queued-card-holds.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index fdf8e6d2b0df..146cf63331cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -207,6 +207,7 @@ describe('a Stop holds the queue, in order', () => { // Still winding down: the paused row shows, but neither Resume nor "Send message?" while it runs. expect(views.at(-1)).toMatchObject({ working: true, header: true, dialog: false }) await rig.settleAccepted(working, 'stopped') + await vi.advanceTimersByTimeAsync(5) await laneDrained() await eventually(() => expect(views.at(-1)).toMatchObject({ header: true, dialog: true })) expect(views.at(-1)?.button).toBe('resume')